diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..437f6d74 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Keep upstream plugin sources recognizable and collapsed in GitHub reviews. +ui/flutter/third_party/** linguist-vendored whitespace=-blank-at-eol,-blank-at-eof diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2614237..e2399b60 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -594,7 +594,7 @@ jobs: overwrite: true build-ios: if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }} - runs-on: macos-14 + runs-on: macos-26 needs: [get-release] steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/scripts/check_l10n.dart b/.github/workflows/scripts/check_l10n.dart index a542a51f..712f5419 100644 --- a/.github/workflows/scripts/check_l10n.dart +++ b/.github/workflows/scripts/check_l10n.dart @@ -6,15 +6,19 @@ // What it checks: // - Every ARB file uses the repository's canonical two-space JSON formatting. // - Every app_.arb file declares the matching @@locale value. -// - Message values are not empty. -// - Every locale has exactly the same message keys as app_en.arb. -// - Every translation preserves the placeholder variables used by app_en.arb. +// - Message values are not empty (warning only). +// - Every locale has the same message keys as app_en.arb (warning only). +// - Every translation preserves the placeholder variables used by app_en.arb +// (warning only). // // Notes: // - This script does not judge translation quality. // - It does not generate localization code or fully parse ICU MessageFormat. // `flutter gen-l10n` runs immediately afterwards in CI to validate ICU syntax // and generate app_localizations*.dart. +// - The script exits with a failure only for malformed JSON, invalid ARB +// formatting, or a mismatched @@locale value. Translation completeness is +// reported as warnings; generation and analysis remain the compile-time gate. import 'dart:convert'; import 'dart:io'; @@ -49,27 +53,24 @@ void main() { keys.where((key) => (messages[key] as String).trim().isEmpty).toList() ..sort(); if (empty.isNotEmpty) { - stderr.writeln('${file.path}: empty messages $empty'); - failed = true; + _warning('${file.path}: empty messages $empty'); } if (file.path == templateFile.path) continue; final missing = templateKeys.difference(keys); final extra = keys.difference(templateKeys); if (missing.isNotEmpty || extra.isNotEmpty) { - stderr.writeln( + _warning( '${file.path}: missing ${missing.toList()..sort()}, extra ${extra.toList()..sort()}', ); - failed = true; } for (final key in templateKeys.intersection(keys)) { final expected = _placeholders(template[key] as String); final actual = _placeholders(messages[key] as String); if (!_sameSet(expected, actual)) { - stderr.writeln( + _warning( '${file.path}: $key placeholders $actual do not match $expected', ); - failed = true; } } } @@ -102,3 +103,5 @@ Set _placeholders(String message) => RegExp( /// Compares sets without depending on iteration order. bool _sameSet(Set left, Set right) => left.length == right.length && left.containsAll(right); + +void _warning(String message) => stderr.writeln('warning: $message'); diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dda4fa5e..745fe371 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -91,7 +91,8 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} - name: Test webview if: runner.os == 'Windows' - run: go test -tags webview -v -count=1 -timeout 300s ./internal/webview/goprovider/ + # Repeat close/reopen coverage to catch browser shutdown races. + run: go test -tags webview -v -count=3 -timeout 300s ./internal/webview/goprovider/ env: CGO_ENABLED: 1 - name: Test webview @@ -213,7 +214,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ macos-14, ubuntu-latest ] + os: [ macos-26, ubuntu-latest ] needs: [ test ] steps: - uses: actions/checkout@v3 diff --git a/README.md b/README.md index b74a1c76..a5f84853 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Connect Gopeed to an AI agent and manage downloads with natural language. For ex Gopeed 2.0.0 is currently in public beta, introducing a redesigned interface, a native communication architecture that connects desktop and mobile clients directly to the Go core through FFI, a more consistent cross-platform experience, improved task management, more flexible API support, and MCP-based AI agent integration. Some features may still be incomplete or unstable, so please try it and report any issues you encounter. -- [Download Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) +- [Download Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) Once the features and stability meet our release standards, we will publish the official Gopeed 2.0.0 release. Beta users will be able to upgrade directly to the final release, while existing stable users will not be automatically moved onto the beta channel. diff --git a/README_ja-JP.md b/README_ja-JP.md index 30fb6082..8e0034f2 100644 --- a/README_ja-JP.md +++ b/README_ja-JP.md @@ -63,7 +63,7 @@ Gopeed を AI Agent と連携すると、自然言語でダウンロードを管 Gopeed 2.0.0 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから FFI で Go コアへ直接接続するネイティブ通信アーキテクチャ、より統一されたクロスプラットフォーム体験、強化されたタスク管理、より柔軟な API、MCP による AI Agent 連携機能を導入しています。一部の機能はまだ不完全または不安定な場合がありますので、ぜひお試しいただき、問題があればフィードバックをお寄せください。 -- [Gopeed 2.0.0 Beta 1 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) +- [Gopeed 2.0.0 Beta 2 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) 機能と安定性が正式リリースの基準に達した時点で、Gopeed 2.0.0 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。 diff --git a/README_vi-VN.md b/README_vi-VN.md index f508f657..97af5bcf 100644 --- a/README_vi-VN.md +++ b/README_vi-VN.md @@ -63,7 +63,7 @@ Sau khi kết nối Gopeed với AI Agent, bạn có thể quản lý tải xu Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai, với giao diện được thiết kế lại, kiến trúc giao tiếp native kết nối trực tiếp ứng dụng desktop và di động với lõi Go qua FFI, trải nghiệm đa nền tảng nhất quán hơn, khả năng quản lý tác vụ được cải thiện, API linh hoạt hơn và khả năng tích hợp AI Agent qua MCP. Một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định, vì vậy hãy dùng thử và gửi phản hồi nếu bạn gặp vấn đề. -- [Tải Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) +- [Tải Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) Khi các tính năng và độ ổn định đáp ứng tiêu chuẩn phát hành, chúng tôi sẽ phát hành Gopeed 2.0.0 chính thức. Người dùng bản beta có thể nâng cấp trực tiếp lên bản chính thức, trong khi người dùng bản ổn định hiện tại sẽ không tự động được chuyển sang kênh beta. diff --git a/README_zh-CN.md b/README_zh-CN.md index 2f70a3ce..46446941 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -63,7 +63,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go Gopeed 2.0.0 目前处于公开 Beta 测试阶段,引入了全新界面、通过 FFI 直连 Go 核心的原生通信架构、更一致的跨平台体验、更完善的任务管理、更灵活的 API,以及用于 AI Agent 接入的 MCP 能力。部分功能仍可能不够完善或稳定,欢迎提前体验并向我们反馈问题。 -- [下载 Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) +- [下载 Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) 当功能完整度和稳定性达到正式发布标准后,我们会发布 Gopeed 2.0.0 正式版。已安装 Beta 版本的用户可以直接升级到最终正式版,现有稳定版用户则不会被自动切换到 Beta 渠道。 diff --git a/README_zh-TW.md b/README_zh-TW.md index 6b981653..0a05b7a9 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -63,7 +63,7 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、 Gopeed 2.0.0 目前處於公開 Beta 測試階段,引入了全新介面、透過 FFI 直連 Go 核心的原生通訊架構、更一致的跨平台體驗、更完善的任務管理、更靈活的 API,以及透過 MCP 串接 AI Agent 的能力。部分功能仍可能不夠完善或穩定,歡迎搶先體驗並向我們回報問題。 -- [下載 Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1) +- [下載 Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) 當功能完整度與穩定性達到正式發布標準後,我們會發布 Gopeed 2.0.0 正式版。已安裝 Beta 版本的使用者可以直接升級至最終正式版,現有穩定版使用者則不會被自動切換至 Beta 頻道。 diff --git a/bind/desktop/main.go b/bind/desktop/main.go index cedbe38f..b756a1dd 100644 --- a/bind/desktop/main.go +++ b/bind/desktop/main.go @@ -6,9 +6,15 @@ package main typedef void (*TaskEventCallback)(char* payload); +typedef void (*InvokeResultCallback)(uint64_t request_id, int success, char* payload); + static void callTaskEventCallback(uintptr_t callback, char* payload) { ((TaskEventCallback)callback)(payload); } + +static void callInvokeResultCallback(uintptr_t callback, uint64_t request_id, int success, char* payload) { + ((InvokeResultCallback)callback)(request_id, success, payload); +} */ import "C" import ( @@ -16,6 +22,7 @@ import ( "fmt" "unsafe" + nativebridge "github.com/GopeedLab/gopeed/bind/native" "github.com/GopeedLab/gopeed/pkg/rest" "github.com/GopeedLab/gopeed/pkg/rest/model" ) @@ -35,12 +42,13 @@ func Start(cfg *C.char) (int, *C.char) { if err != nil { return 0, C.CString(err.Error()) } + nativebridge.ResumeInvokes() return realPort, nil } //export Stop func Stop() { - rest.Stop() + nativebridge.Stop() } //export GetAPIServerState @@ -75,14 +83,39 @@ func apiServerResult(state *model.APIServerState, err error) string { return string(data) } -//export Invoke -func Invoke(method *C.char, path *C.char, query *C.char, body *C.char) *C.char { - return C.CString(rest.Invoke( +//export InvokeAsync +func InvokeAsync( + method *C.char, + path *C.char, + query *C.char, + body *C.char, + requestID C.ulonglong, + callback C.uintptr_t, +) { + if callback == 0 { + return + } + complete := func(result string, err error) { + success := C.int(1) + payload := result + if err != nil { + success = 0 + payload = err.Error() + } + C.callInvokeResultCallback( + callback, + C.uint64_t(requestID), + success, + C.CString(payload), + ) + } + nativebridge.InvokeAsync( C.GoString(method), C.GoString(path), C.GoString(query), C.GoString(body), - )) + complete, + ) } //export SubscribeTaskEvents diff --git a/bind/mobile/main.go b/bind/mobile/main.go index ab45ae32..380671b3 100644 --- a/bind/mobile/main.go +++ b/bind/mobile/main.go @@ -5,7 +5,9 @@ import "C" import ( "encoding/json" + nativebridge "github.com/GopeedLab/gopeed/bind/native" "github.com/GopeedLab/gopeed/internal/webview/rpcprovider" + goapi "github.com/GopeedLab/gopeed/pkg/api" "github.com/GopeedLab/gopeed/pkg/rest" "github.com/GopeedLab/gopeed/pkg/rest/model" ) @@ -18,11 +20,15 @@ func Start(cfg string) (int, error) { config.ProductionMode = true config.NativeMode = true applyWebViewProvider(&config) - return rest.Start(&config) + port, err := rest.Start(&config) + if err == nil { + nativebridge.ResumeInvokes() + } + return port, err } func Stop() { - rest.Stop() + nativebridge.Stop() } func GetAPIServerState() string { @@ -50,8 +56,21 @@ func apiServerResult(state *model.APIServerState, err error) string { return string(data) } -func Invoke(method, path, query, body string) string { - return rest.Invoke(method, path, query, body) +type InvokeResultListener interface { + OnResult(requestID int64, success bool, payload string) +} + +func InvokeAsync(method, path, query, body string, requestID int64, listener InvokeResultListener) { + if listener == nil { + return + } + nativebridge.InvokeAsync(method, path, query, body, func(result string, err error) { + if err != nil { + listener.OnResult(requestID, false, err.Error()) + return + } + listener.OnResult(requestID, true, result) + }) } type TaskEventListener interface { @@ -72,3 +91,14 @@ func applyWebViewProvider(config *model.StartConfig) { } config.WebViewProvider = rpcprovider.New(*config.WebViewRPCConfig) } + +func LiveActivityTaskEventMask() int64 { + return int64( + goapi.TaskEventDone | + goapi.TaskEventError | + goapi.TaskEventStart | + goapi.TaskEventProgress | + goapi.TaskEventPause | + goapi.TaskEventDelete, + ) +} diff --git a/bind/native/invoke.go b/bind/native/invoke.go new file mode 100644 index 00000000..55dc1a62 --- /dev/null +++ b/bind/native/invoke.go @@ -0,0 +1,240 @@ +// Package nativebridge contains transport-neutral behavior shared by the +// Desktop C FFI and gomobile bridge adapters. +package nativebridge + +import ( + "errors" + "fmt" + "sync" + "time" + + "github.com/GopeedLab/gopeed/pkg/rest" +) + +// StopTimeout bounds native shutdown even when an in-process request never returns. +const StopTimeout = 3 * time.Second + +var errNativeBridgeStopping = errors.New("native bridge is stopping") + +type invokeRequest struct { + method string + path string + query string + body string +} + +type invokeTask struct { + id uint64 + request invokeRequest + complete func(result string, err error) + once sync.Once +} + +func (t *invokeTask) finish(result string, err error) { + t.once.Do(func() { + completeCallback(t.complete, result, err) + }) +} + +// InvokeAsync starts an in-process API request and completes it through the +// supplied callback. Desktop and mobile adapters share this executor. +func InvokeAsync(method, path, query, body string, callback func(result string, err error)) { + if callback == nil { + return + } + task := invokeTask{ + request: invokeRequest{ + method: method, + path: path, + query: query, + body: body, + }, + complete: callback, + } + if !getInvokeExecutor().submit(task) { + completeCallback(callback, "", errNativeBridgeStopping) + } +} + +// ResumeInvokes enables request submission after the native runtime starts. +func ResumeInvokes() { + getInvokeExecutor().resume() +} + +// PauseInvokesAndWait prevents new submissions and gives running requests a +// bounded opportunity to finish before a native runtime is stopped. +// Requests still pending after StopTimeout are failed and detached so shutdown +// can continue without invoking their callbacks a second time. +func PauseInvokesAndWait() bool { + return getInvokeExecutor().pauseAndWait(StopTimeout) +} + +// Stop pauses native requests and shuts down the shared runtime, but never +// keeps the host application waiting longer than StopTimeout. Cleanup that +// cannot finish within the deadline is allowed to continue in the background. +func Stop() { + stopWithin(getInvokeExecutor(), rest.Stop, StopTimeout) +} + +func stopWithin(executor *invokeExecutor, cleanup func(), timeout time.Duration) { + deadline := time.Now().Add(timeout) + executor.pauseAndWait(time.Until(deadline)) + + stopped := make(chan struct{}) + go func() { + defer close(stopped) + cleanup() + }() + + remaining := time.Until(deadline) + if remaining <= 0 { + return + } + timer := time.NewTimer(remaining) + defer timer.Stop() + select { + case <-stopped: + case <-timer.C: + } +} + +var ( + invokeExecutorOnce sync.Once + sharedInvoker *invokeExecutor +) + +func getInvokeExecutor() *invokeExecutor { + invokeExecutorOnce.Do(func() { + sharedInvoker = newInvokeExecutor(func(request invokeRequest) string { + return rest.Dispatch(request.method, request.path, request.query, request.body) + }) + }) + return sharedInvoker +} + +// invokeExecutor starts every native API request in its own goroutine, matching +// net/http's request concurrency. It only tracks them to support bounded drain +// during shutdown; one stuck request must never prevent another from starting. +type invokeExecutor struct { + mu sync.Mutex + closed bool + accepting bool + nextID uint64 + pending map[uint64]*invokeTask + drained chan struct{} + running sync.WaitGroup + handler func(invokeRequest) string +} + +func newInvokeExecutor(handler func(invokeRequest) string) *invokeExecutor { + drained := make(chan struct{}) + close(drained) + return &invokeExecutor{ + handler: handler, + accepting: true, + pending: make(map[uint64]*invokeTask), + drained: drained, + } +} + +func (e *invokeExecutor) submit(task invokeTask) bool { + e.mu.Lock() + if e.closed || !e.accepting { + e.mu.Unlock() + return false + } + if len(e.pending) == 0 { + e.drained = make(chan struct{}) + } + taskID := e.nextID + e.nextID++ + task.id = taskID + taskPtr := &task + e.pending[taskID] = taskPtr + e.running.Add(1) + e.mu.Unlock() + + go e.run(taskPtr) + return true +} + +func (e *invokeExecutor) resume() { + e.mu.Lock() + e.accepting = true + e.mu.Unlock() +} + +func (e *invokeExecutor) pauseAndWait(timeout time.Duration) bool { + e.mu.Lock() + e.accepting = false + if len(e.pending) == 0 { + e.mu.Unlock() + return true + } + drained := e.drained + e.mu.Unlock() + + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-drained: + return true + case <-timer.C: + } + + e.mu.Lock() + if len(e.pending) == 0 { + e.mu.Unlock() + return true + } + pending := make([]*invokeTask, 0, len(e.pending)) + for _, task := range e.pending { + pending = append(pending, task) + } + e.pending = make(map[uint64]*invokeTask) + close(e.drained) + e.mu.Unlock() + + for _, task := range pending { + task.finish("", errNativeBridgeStopping) + } + return false +} + +func (e *invokeExecutor) close() { + e.mu.Lock() + e.closed = true + e.accepting = false + e.mu.Unlock() + e.running.Wait() +} + +func (e *invokeExecutor) run(task *invokeTask) { + defer e.running.Done() + result, err := e.execute(task.request) + task.finish(result, err) + e.mu.Lock() + if _, exists := e.pending[task.id]; exists { + delete(e.pending, task.id) + if len(e.pending) == 0 { + close(e.drained) + } + } + e.mu.Unlock() +} + +func (e *invokeExecutor) execute(request invokeRequest) (result string, err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("invoke panic: %v", recovered) + } + }() + return e.handler(request), nil +} + +func completeCallback(callback func(string, error), result string, err error) { + defer func() { + _ = recover() + }() + callback(result, err) +} diff --git a/bind/native/invoke_test.go b/bind/native/invoke_test.go new file mode 100644 index 00000000..649bc033 --- /dev/null +++ b/bind/native/invoke_test.go @@ -0,0 +1,258 @@ +package nativebridge + +import ( + "runtime" + "strconv" + "sync" + "testing" + "time" +) + +func TestInvokeExecutorStartsEveryRequestIndependently(t *testing.T) { + const taskCount = 64 + release := make(chan struct{}) + started := make(chan struct{}, taskCount) + executor := newInvokeExecutor(func(request invokeRequest) string { + started <- struct{}{} + <-release + return request.path + }) + + var completed sync.WaitGroup + completed.Add(taskCount) + for i := range taskCount { + accepted := executor.submit(invokeTask{ + request: invokeRequest{path: strconv.Itoa(i)}, + complete: func(string, error) { + completed.Done() + }, + }) + if !accepted { + t.Fatalf("request %d was rejected", i) + } + } + + // Every handler must start before any of them is released. A worker pool + // would stall here once all of its workers were occupied. + for range taskCount { + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("a request waited behind other blocked requests") + } + } + + close(release) + completed.Wait() + executor.close() +} + +func TestInvokeExecutorConvertsPanicsToErrors(t *testing.T) { + executor := newInvokeExecutor(func(invokeRequest) string { + panic("boom") + }) + completed := make(chan error, 1) + executor.submit(invokeTask{complete: func(_ string, err error) { + completed <- err + }}) + select { + case err := <-completed: + if err == nil || err.Error() != "invoke panic: boom" { + t.Fatalf("unexpected error: %v", err) + } + case <-time.After(time.Second): + t.Fatal("panic result was not completed") + } + executor.close() +} + +func TestInvokeExecutorSurvivesCallbackPanic(t *testing.T) { + executor := newInvokeExecutor(func(request invokeRequest) string { + return request.path + }) + executor.submit(invokeTask{complete: func(string, error) { + panic("callback failed") + }}) + + completed := make(chan string, 1) + executor.submit(invokeTask{ + request: invokeRequest{path: "next"}, + complete: func(result string, _ error) { + completed <- result + }, + }) + select { + case result := <-completed: + if result != "next" { + t.Fatalf("unexpected result: %q", result) + } + case <-time.After(time.Second): + t.Fatal("worker stopped after callback panic") + } + executor.close() +} + +func TestInvokeExecutorPausesAndDrains(t *testing.T) { + release := make(chan struct{}) + started := make(chan struct{}) + executor := newInvokeExecutor(func(request invokeRequest) string { + if request.path == "running" { + close(started) + <-release + } + return request.path + }) + completed := make(chan struct{}) + if !executor.submit(invokeTask{ + request: invokeRequest{path: "running"}, + complete: func(string, error) { + close(completed) + }, + }) { + t.Fatal("initial task was rejected") + } + <-started + + drained := make(chan struct{}) + go func() { + executor.pauseAndWait(time.Second) + close(drained) + }() + deadline := time.Now().Add(time.Second) + for { + executor.mu.Lock() + paused := !executor.accepting + executor.mu.Unlock() + if paused { + break + } + if time.Now().After(deadline) { + t.Fatal("executor did not pause") + } + runtime.Gosched() + } + select { + case <-drained: + t.Fatal("pause returned before the running request completed") + default: + } + if executor.submit(invokeTask{}) { + t.Fatal("paused executor accepted a new request") + } + + close(release) + <-completed + select { + case <-drained: + case <-time.After(time.Second): + t.Fatal("pause did not return after pending requests drained") + } + + executor.resume() + resumed := make(chan struct{}) + if !executor.submit(invokeTask{complete: func(string, error) { + close(resumed) + }}) { + t.Fatal("resumed executor rejected a request") + } + <-resumed + executor.close() +} + +func TestInvokeExecutorPauseTimesOutAndFailsPendingRequests(t *testing.T) { + release := make(chan struct{}) + started := make(chan struct{}) + executor := newInvokeExecutor(func(request invokeRequest) string { + started <- struct{}{} + <-release + return request.path + }) + + errors := make(chan error, 2) + for _, path := range []string{"first", "second"} { + if !executor.submit(invokeTask{ + request: invokeRequest{path: path}, + complete: func(_ string, err error) { + errors <- err + }, + }) { + t.Fatalf("request %q was rejected", path) + } + } + for range 2 { + <-started + } + + const timeout = 30 * time.Millisecond + startedAt := time.Now() + if executor.pauseAndWait(timeout) { + t.Fatal("pause unexpectedly reported a complete drain") + } + if elapsed := time.Since(startedAt); elapsed < timeout || elapsed > 10*timeout { + t.Fatalf("pause returned after %s, want close to %s", elapsed, timeout) + } + for range 2 { + select { + case err := <-errors: + if err == nil || err.Error() != errNativeBridgeStopping.Error() { + t.Fatalf("unexpected timeout error: %v", err) + } + case <-time.After(time.Second): + t.Fatal("pending request was not failed after the timeout") + } + } + + close(release) + executor.close() + select { + case err := <-errors: + t.Fatalf("request callback ran more than once: %v", err) + default: + } +} + +func TestStopWithinUsesOneDeadlineForRequestsAndCleanup(t *testing.T) { + releaseRequest := make(chan struct{}) + requestStarted := make(chan struct{}) + executor := newInvokeExecutor(func(invokeRequest) string { + close(requestStarted) + <-releaseRequest + return "done" + }) + requestResult := make(chan error, 1) + executor.submit(invokeTask{complete: func(_ string, err error) { + requestResult <- err + }}) + <-requestStarted + + releaseCleanup := make(chan struct{}) + cleanupStarted := make(chan struct{}) + cleanup := func() { + close(cleanupStarted) + <-releaseCleanup + } + + const timeout = 40 * time.Millisecond + startedAt := time.Now() + stopWithin(executor, cleanup, timeout) + if elapsed := time.Since(startedAt); elapsed < timeout || elapsed > 10*timeout { + t.Fatalf("stop returned after %s, want close to %s", elapsed, timeout) + } + select { + case <-cleanupStarted: + case <-time.After(time.Second): + t.Fatal("cleanup was not started after the request deadline") + } + select { + case err := <-requestResult: + if err == nil || err.Error() != errNativeBridgeStopping.Error() { + t.Fatalf("unexpected request result: %v", err) + } + case <-time.After(time.Second): + t.Fatal("timed-out request was not failed") + } + + close(releaseRequest) + close(releaseCleanup) + executor.close() +} diff --git a/go.mod b/go.mod index 13db5774..b01d1f1e 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,15 @@ module github.com/GopeedLab/gopeed go 1.25.4 require ( - github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5 + codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0 + github.com/GopeedLab/webview_go v0.0.0-20260915010614-2567e492369a github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 github.com/bodgit/sevenzip v1.6.1 github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc github.com/go-git/go-git/v5 v5.8.1 + github.com/google/jsonschema-go v0.4.3 github.com/gorilla/handlers v1.5.1 github.com/gorilla/mux v1.8.0 github.com/imroc/req/v3 v3.52.2 @@ -21,6 +23,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 github.com/rs/zerolog v1.31.0 + github.com/tetratelabs/wazero v1.12.0 github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad go.etcd.io/bbolt v1.4.3 golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 @@ -38,7 +41,6 @@ require ( github.com/felixge/fgprof v0.9.5 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/jsonschema-go v0.4.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect @@ -156,7 +158,7 @@ require ( golang.org/x/crypto v0.48.0 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.50.0 - golang.org/x/sys v0.41.0 // indirect + golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.34.0 golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.42.0 // indirect diff --git a/go.sum b/go.sum index 9c4680f4..98c4c2eb 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2k cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0 h1:AqNFl60o4xhTTOxmzAvSf7pazXHKBGuV5ATw7sETIRA= +codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0/go.mod h1:XM6eW8CSgAuZRKRynm5nDiSNedm0rav30IychqygOHE= crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk= crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= @@ -23,8 +25,8 @@ filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmG filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5 h1:86LVTaNDM/j/uTAD2HedkXNO9LIQlt642VAdbU/0D/M= -github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0= +github.com/GopeedLab/webview_go v0.0.0-20260915010614-2567e492369a h1:Vzz+8aMVX9ZPPpkxgxa7mCorrAgdXCZL5EunOpOBvao= +github.com/GopeedLab/webview_go v0.0.0-20260915010614-2567e492369a/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -552,6 +554,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA= github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A= github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= @@ -732,6 +736,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= diff --git a/internal/fetcher/fetcher.go b/internal/fetcher/fetcher.go index 4a1374c3..8d3a7821 100644 --- a/internal/fetcher/fetcher.go +++ b/internal/fetcher/fetcher.go @@ -106,7 +106,13 @@ func (s *SchemeFilter) Match(uri string) bool { case FilterTypeUrl: return strings.HasPrefix(uriUpper, patternUpper+":") case FilterTypeFile: - return strings.HasSuffix(uriUpper, "."+patternUpper) + // Strip query and fragment before matching so URLs like + // https://example.com/video.m3u8?sign=abc still route by extension. + clean := uri + if idx := strings.IndexAny(clean, "?#"); idx >= 0 { + clean = clean[:idx] + } + return strings.HasSuffix(strings.ToUpper(clean), "."+patternUpper) case FilterTypeBase64: return strings.HasPrefix(uriUpper, "DATA:"+patternUpper+";BASE64,") } diff --git a/internal/protocol/hls/config.go b/internal/protocol/hls/config.go new file mode 100644 index 00000000..2c06d5f0 --- /dev/null +++ b/internal/protocol/hls/config.go @@ -0,0 +1,30 @@ +package hls + +const ( + defaultSegmentConnections = 8 + defaultMaxRetries = 5 + defaultTimeoutSeconds = 30 + + // maxPrefetchSizeSegments caps the HEAD probing done at resolve time. + // Playlists larger than this skip size prefetch entirely. + maxPrefetchSizeSegments = 500 +) + +type config struct { + SegmentConnections int `json:"segmentConnections"` + MaxRetries int `json:"maxRetries"` + TimeoutSeconds int `json:"timeoutSeconds"` + PrefetchContentLength bool `json:"prefetchContentLength"` +} + +func (c *config) normalize() { + if c.SegmentConnections <= 0 { + c.SegmentConnections = defaultSegmentConnections + } + if c.MaxRetries < 0 { + c.MaxRetries = defaultMaxRetries + } + if c.TimeoutSeconds <= 0 { + c.TimeoutSeconds = defaultTimeoutSeconds + } +} diff --git a/internal/protocol/hls/crypto.go b/internal/protocol/hls/crypto.go new file mode 100644 index 00000000..58379a4f --- /dev/null +++ b/internal/protocol/hls/crypto.go @@ -0,0 +1,59 @@ +package hls + +import ( + "crypto/aes" + "crypto/cipher" + "fmt" +) + +// decryptAES128 decrypts an AES-128-CBC encrypted segment and strips PKCS7 +// padding. data length must be a multiple of the AES block size. +func decryptAES128(data, key, iv []byte) ([]byte, error) { + if len(key) != 16 { + return nil, fmt.Errorf("invalid AES-128 key size %d", len(key)) + } + if len(iv) != 16 { + return nil, fmt.Errorf("invalid AES-128 IV size %d", len(iv)) + } + if len(data) == 0 || len(data)%aes.BlockSize != 0 { + return nil, fmt.Errorf("invalid AES-128 data size %d", len(data)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + plain := make([]byte, len(data)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, data) + return pkcs7Unpad(plain, aes.BlockSize) +} + +func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) { + if len(data) == 0 { + return nil, fmt.Errorf("empty plaintext") + } + pad := int(data[len(data)-1]) + if pad == 0 || pad > blockSize || pad > len(data) { + return nil, fmt.Errorf("invalid PKCS7 padding") + } + for _, b := range data[len(data)-pad:] { + if int(b) != pad { + return nil, fmt.Errorf("invalid PKCS7 padding") + } + } + return data[:len(data)-pad], nil +} + +// sequenceIV derives the default per-segment IV mandated by RFC 8216: the +// segment media sequence number as a 16-byte big-endian integer. +func sequenceIV(sequence int64) []byte { + iv := make([]byte, 16) + iv[8] = byte(sequence >> 56) + iv[9] = byte(sequence >> 48) + iv[10] = byte(sequence >> 40) + iv[11] = byte(sequence >> 32) + iv[12] = byte(sequence >> 24) + iv[13] = byte(sequence >> 16) + iv[14] = byte(sequence >> 8) + iv[15] = byte(sequence) + return iv +} diff --git a/internal/protocol/hls/crypto_test.go b/internal/protocol/hls/crypto_test.go new file mode 100644 index 00000000..05f18423 --- /dev/null +++ b/internal/protocol/hls/crypto_test.go @@ -0,0 +1,66 @@ +package hls + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "testing" +) + +func pkcs7Pad(data []byte, blockSize int) []byte { + pad := blockSize - len(data)%blockSize + return append(data, bytes.Repeat([]byte{byte(pad)}, pad)...) +} + +func TestDecryptAES128Roundtrip(t *testing.T) { + key := make([]byte, 16) + iv := make([]byte, 16) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + if _, err := rand.Read(iv); err != nil { + t.Fatal(err) + } + plain := []byte("hello hls segment payload, padded to block size!") + padded := pkcs7Pad(plain, aes.BlockSize) + block, err := aes.NewCipher(key) + if err != nil { + t.Fatal(err) + } + encrypted := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(encrypted, padded) + + got, err := decryptAES128(encrypted, key, iv) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, plain) { + t.Errorf("roundtrip mismatch: %q", got) + } +} + +func TestDecryptAES128InvalidInput(t *testing.T) { + if _, err := decryptAES128([]byte{1, 2, 3}, make([]byte, 16), make([]byte, 16)); err == nil { + t.Error("non-block-aligned data should fail") + } + if _, err := decryptAES128(make([]byte, 16), make([]byte, 15), make([]byte, 16)); err == nil { + t.Error("short key should fail") + } + if _, err := decryptAES128(make([]byte, 16), make([]byte, 16), make([]byte, 16)); err == nil { + t.Error("all-zero block has invalid padding and should fail") + } +} + +func TestSequenceIV(t *testing.T) { + iv := sequenceIV(1) + if len(iv) != 16 || iv[15] != 1 { + t.Errorf("sequence 1 IV wrong: %v", iv) + } + iv = sequenceIV(0x0102030405060708) + for i, want := range []byte{1, 2, 3, 4, 5, 6, 7, 8} { + if iv[8+i] != want { + t.Errorf("sequence IV byte %d: want %d got %d", i, want, iv[8+i]) + } + } +} diff --git a/internal/protocol/hls/doc.go b/internal/protocol/hls/doc.go new file mode 100644 index 00000000..78699556 --- /dev/null +++ b/internal/protocol/hls/doc.go @@ -0,0 +1,24 @@ +// Package hls implements native HTTP Live Streaming downloads for m3u8 +// playlists. A task produces exactly one merged, playable output file: +// MPEG-2 TS segments are concatenated as-is, fMP4 streams are merged as +// init section followed by the media fragments, and AES-128 encrypted +// segments are decrypted before they are stored. +// +// Supported subset: +// - VOD media playlists (EXT-X-ENDLIST present) and master playlists whose +// selected variant carries its own audio +// - AES-128 with the identity KEYFORMAT, explicit IV or media-sequence +// derived IV, including key rotation mid-playlist +// - EXT-X-BYTERANGE (including offset inheritance) and fMP4 init sections +// via a single, constant EXT-X-MAP +// +// Everything the merge pipeline cannot reproduce faithfully is rejected with +// an explicit error instead of silently producing a broken output file: +// - live streams (no EXT-X-ENDLIST) and SAMPLE-AES / DRM encryption +// - EXT-X-GAP (would leave a hole) and EXT-X-DISCONTINUITY (broken timeline) +// - changing init sections (a repeated EXT-X-MAP is compared by all its +// attributes, not just the URI) and encrypted EXT-X-MAP without an +// explicit IV +// - separate audio renditions referenced by the selected variant (the +// merged output would have no audio) and non-identity KEYFORMATs +package hls diff --git a/internal/protocol/hls/fetcher.go b/internal/protocol/hls/fetcher.go new file mode 100644 index 00000000..7259a9b0 --- /dev/null +++ b/internal/protocol/hls/fetcher.go @@ -0,0 +1,1001 @@ +package hls + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/GopeedLab/gopeed/internal/controller" + "github.com/GopeedLab/gopeed/internal/fetcher" + "github.com/GopeedLab/gopeed/internal/httpclient" + "github.com/GopeedLab/gopeed/pkg/base" +) + +// ReqExtra mirrors the HTTP protocol extra: the create dialog attaches HTTP +// style options to m3u8 URLs (same JSON keys), so the same shape is decoded. +type ReqExtra struct { + Method string `json:"method,omitempty"` + Header map[string]string `json:"header,omitempty"` + Body string `json:"body,omitempty"` +} + +func copyReqExtra(extra *ReqExtra) *ReqExtra { + copied := &ReqExtra{} + if extra == nil { + return copied + } + copied.Method = extra.Method + copied.Body = extra.Body + if extra.Header != nil { + copied.Header = make(map[string]string, len(extra.Header)) + for key, value := range extra.Header { + copied.Header[key] = value + } + } + return copied +} + +// fetcherState is the resolved download plan persisted for resume. StagingID +// gives the task its own staging folder (two tasks downloading the same URL +// into the same directory never share segment files) and survives restarts +// through the persisted state. StagingDir pins the absolute folder so user +// renames or moves of the task output do not orphan the staged segments. +type fetcherState struct { + MediaURL string `json:"mediaURL"` + OutputName string `json:"outputName"` + Segments []*Segment `json:"segments"` + StagingID string `json:"stagingID,omitempty"` + StagingDir string `json:"stagingDir,omitempty"` +} + +// Stats is the protocol task statistics snapshot. +type Stats struct { + SegmentsTotal int `json:"segmentsTotal"` + SegmentsDone int `json:"segmentsDone"` + SegmentsFailed int `json:"segmentsFailed"` +} + +// tempDirPrefix separates HLS staging folders from regular task files. +const tempDirPrefix = ".gopeed-hls-" + +// hlsRun holds every piece of state that belongs to one Start..Pause/complete +// cycle. Runs never share mutable state: a Pause that is immediately followed +// by a Start replaces the whole run, so workers of the previous run cannot +// race with the next one. The run also snapshots the plan, request headers, +// client and merge target so concurrent Patch calls cannot change them under +// a running download. +type hlsRun struct { + ctx context.Context + cancel context.CancelFunc + // wg covers the queue producer AND the segment workers: Pause must not + // return while the producer can still hand out segment indexes. + wg sync.WaitGroup + done chan struct{} // closed when the supervisor has fully exited + + segs []*Segment // plan snapshot, read-only for the duration of the run + extra *ReqExtra // header snapshot (deep copy) + client *http.Client + outputPath string // merge target snapshot + tempDir string + + keyCache map[string][]byte + keyCacheMu sync.Mutex + + stagingID string // identity of the staging folder, bound into the journal + planHash string // fingerprint of the download plan, validated on resume + journal map[int64]int64 + journalPath string + journalMu sync.Mutex + + // downloaded counts bytes of the final output that are safely on disk + // (completed segment files), not raw network traffic: discarded retry + // bytes must never show up as progress. + downloaded atomic.Int64 + doneCount atomic.Int64 + failCount atomic.Int64 + baseBytes int64 // stored bytes restored from the journal at startup + baseDone int // completed segments restored from the journal at startup + pending []int64 // segment indexes to download, set by prepareRun +} + +func (r *hlsRun) statsSnapshot() Stats { + return Stats{ + SegmentsTotal: len(r.segs), + SegmentsDone: r.baseDone + int(r.doneCount.Load()), + SegmentsFailed: int(r.failCount.Load()), + } +} + +type Fetcher struct { + fetcher.DefaultFetcher + meta *fetcher.FetcherMeta + config *config + + // mu guards the fields below and every lifecycle transition. Progress and + // Stats are read from the engine's checkpoint goroutine while a run is + // active; they only take mu to snapshot the active run pointer, then read + // run-owned atomics lock-free. + mu sync.Mutex + extra *ReqExtra + client *http.Client + state *fetcherState + activeRun *hlsRun + closed bool + lastStats Stats + lastDownloaded int64 + + impSession *httpclient.ImpersonationSession +} + +func (f *Fetcher) Setup(ctl *controller.Controller) { + // Initializes Ctl and DoneCh, which Wait() depends on. + f.DefaultFetcher.Setup(ctl) + if f.meta == nil { + f.meta = &fetcher.FetcherMeta{} + } + f.impSession = httpclient.NewImpersonationSession() + f.config = &config{} + f.Ctl.GetConfig(f.config) + f.config.normalize() + // Restored fetchers receive their request through Setup (the engine calls + // it after the Restore builder): parse the extra headers so resumed + // segment/key requests keep Referer/Cookie and other anti-hotlink headers. + f.mu.Lock() + f.reloadExtraLocked() + f.mu.Unlock() +} + +// reloadExtraLocked refreshes f.extra from the typed extra of meta.Req. +func (f *Fetcher) reloadExtraLocked() { + if f.meta == nil || f.meta.Req == nil { + return + } + if err := base.ParseReqExtra[ReqExtra](f.meta.Req); err != nil { + return + } + if e, ok := f.meta.Req.Extra.(*ReqExtra); ok { + f.extra = e + } +} + +func (f *Fetcher) Meta() *fetcher.FetcherMeta { + return f.meta +} + +func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) (err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.meta.Req = req + f.meta.Opts = opts + f.extra = &ReqExtra{} + if err = base.ParseReqExtra[ReqExtra](req); err != nil { + return + } + if e, ok := req.Extra.(*ReqExtra); ok { + f.extra = e + } + if f.client == nil { + f.client = f.buildClient() + } + + state, name, err := f.resolvePlanLocked(context.Background(), "") + if err != nil { + return err + } + state.StagingID = randomStagingID() + f.state = state + // Pin the staging folder now so the very first persisted state already + // carries it: a crash right after create still resumes into the same + // folder. + f.resolveStagingDirLocked() + size := planTotalSize(state.Segments) + f.meta.Res = &base.Resource{ + Range: false, + Files: []*base.FileInfo{ + { + Name: name, + Size: size, + }, + }, + Size: size, + } + return nil +} + +// resolvePlanLocked fetches and parses the playlist into a fresh state. It +// must be called with f.mu held while the fetcher is quiescent. When keepName +// is non-empty the output keeps that file name (used when re-resolving an +// existing task so a refreshed URL does not rename the file). +func (f *Fetcher) resolvePlanLocked(ctx context.Context, keepName string) (*fetcherState, string, error) { + content, finalURL, err := f.fetchText(ctx, f.playlistMethod(), f.meta.Req.URL) + if err != nil { + return nil, "", fmt.Errorf("fetch playlist failed: %w", err) + } + baseURL, err := url.Parse(finalURL) + if err != nil { + return nil, "", err + } + + mediaURL := finalURL + if IsMasterPlaylist(content) { + master, err := ParseMasterPlaylist(content, baseURL) + if err != nil { + return nil, "", err + } + best := PickBestVariant(master.Variants) + if err := checkAudioRendition(master, best); err != nil { + return nil, "", err + } + variantContent, variantURL, err := f.fetchText(ctx, http.MethodGet, best.URI) + if err != nil { + return nil, "", fmt.Errorf("fetch variant playlist failed: %w", err) + } + variantBase, err := url.Parse(variantURL) + if err != nil { + return nil, "", err + } + content = variantContent + baseURL = variantBase + mediaURL = variantURL + } + media, err := ParseMedia(content, baseURL) + if err != nil { + return nil, "", err + } + if err := validateSupported(media); err != nil { + return nil, "", err + } + + outputName := keepName + if outputName == "" { + outputName = DeriveOutputName(mediaURL, media) + } + state := &fetcherState{ + MediaURL: mediaURL, + OutputName: outputName, + Segments: media.Segments, + } + if f.config.PrefetchContentLength { + f.prefetchSize(media.Segments) + } + return state, outputName, nil +} + +func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.activeRun != nil { + return errors.New("task is running; pause it before changing its settings") + } + if req != nil { + cur := f.meta.Req + if cur == nil { + f.meta.Req = req + cur = req + } + if req.URL != "" && req.URL != cur.URL { + cur.URL = req.URL + // The playlist URL changed (e.g. refreshed signed link): drop the + // plan so the engine re-resolves on next start (it does when Res + // is nil). Only explicitly supplied fields are applied, so a + // header-only or rename-only Patch keeps the resolved plan and + // the output path. + f.dropPlanLocked() + } + if err := mergeReqExtra(cur, req); err != nil { + return err + } + if req.Proxy != nil && cur.Proxy != req.Proxy { + cur.Proxy = req.Proxy + // Transport settings changed: rebuild the client before the + // next run. + f.client = nil + } + if len(req.Labels) > 0 { + if cur.Labels == nil { + cur.Labels = make(map[string]string, len(req.Labels)) + } + for key, value := range req.Labels { + cur.Labels[key] = value + } + } + // SkipVerifyCert is a plain bool, so a partial update cannot tell an + // absent field from an explicit false: it is intentionally not merged + // (the HTTP protocol Patch ignores it as well). + } + if opts != nil { + if f.meta.Opts == nil { + f.meta.Opts = opts + } else { + if opts.Name != "" { + f.meta.Opts.Name = opts.Name + } + if opts.Path != "" { + f.meta.Opts.Path = opts.Path + } + } + } + f.reloadExtraLocked() + return nil +} + +// mergeReqExtra merges the typed extra of incoming into cur following the +// HTTP protocol semantics: Method/Body are overridden when non-empty and +// headers are merged per key. +func mergeReqExtra(cur *base.Request, incoming *base.Request) error { + if incoming.Extra == nil { + return nil + } + if err := base.ParseReqExtra[ReqExtra](incoming); err != nil { + return err + } + inc, ok := incoming.Extra.(*ReqExtra) + if !ok { + return nil + } + if err := base.ParseReqExtra[ReqExtra](cur); err != nil { + return err + } + target, ok := cur.Extra.(*ReqExtra) + if !ok { + target = &ReqExtra{} + cur.Extra = target + } + if inc.Method != "" { + target.Method = inc.Method + } + if inc.Body != "" { + target.Body = inc.Body + } + if len(inc.Header) > 0 { + if target.Header == nil { + target.Header = make(map[string]string, len(inc.Header)) + } + for key, value := range inc.Header { + target.Header[key] = value + } + } + return nil +} + +// dropPlanLocked discards the resolved plan after a URL change. The staging +// folder of the dropped plan is removed too: this only runs while the +// fetcher is quiescent, so no run can still be writing into it. +func (f *Fetcher) dropPlanLocked() { + if f.state != nil && f.state.StagingDir != "" { + os.RemoveAll(f.state.StagingDir) + } + f.state = nil + f.meta.Res = nil +} + +// existingOutputNameLocked returns the output file name the task already +// uses, so a re-resolve does not rename an in-progress download. +func (f *Fetcher) existingOutputNameLocked() string { + if f.meta.Opts != nil && f.meta.Opts.Name != "" { + return f.meta.Opts.Name + } + if res := f.meta.Res; res != nil && len(res.Files) > 0 && res.Files[0] != nil { + return res.Files[0].Name + } + return "" +} + +func (f *Fetcher) Start() error { + f.mu.Lock() + if f.closed { + f.mu.Unlock() + return errors.New("hls fetcher is closed") + } + if f.activeRun != nil { + f.mu.Unlock() + return errors.New("hls fetcher already running") + } + if f.state == nil { + if f.meta.Req == nil || f.meta.Res == nil { + f.mu.Unlock() + return errors.New("hls fetcher is not resolved") + } + // Crash window: the engine persists the task (with its resource) + // before the first HLS state checkpoint lands, so a restored task + // can have a resource but no plan. Rebuild the plan instead of + // failing, keeping the existing output name. + if f.client == nil { + f.client = f.buildClient() + } + state, _, err := f.resolvePlanLocked(context.Background(), f.existingOutputNameLocked()) + if err != nil { + f.mu.Unlock() + return fmt.Errorf("re-resolve failed: %w", err) + } + f.state = state + size := planTotalSize(state.Segments) + if res := f.meta.Res; res != nil { + res.Size = size + if len(res.Files) > 0 && res.Files[0] != nil { + res.Files[0].Size = size + } + } + } + // The client is built here, before any worker exists, so segment + // goroutines never race on lazy initialization. + if f.client == nil { + f.client = f.buildClient() + } + if f.meta.Res == nil { + // A restored plan without a resource cannot determine its output + // path; the engine re-resolves when Res is nil. + f.mu.Unlock() + return errors.New("hls fetcher is not resolved") + } + + ctx, cancel := context.WithCancel(context.Background()) + run := &hlsRun{ + ctx: ctx, + cancel: cancel, + done: make(chan struct{}), + segs: f.state.Segments, + extra: copyReqExtra(f.extra), + client: f.client, + outputPath: f.meta.SingleFilepath(), + stagingID: f.state.StagingID, + planHash: planFingerprint(f.state), + tempDir: f.resolveStagingDirLocked(), + keyCache: make(map[string][]byte), + journal: make(map[int64]int64), + } + // Prepare staging and the completed-segment counters BEFORE publishing + // the run: Stats/Progress may observe it immediately afterwards and must + // never read half-initialized plain fields. + if err := f.prepareRun(run); err != nil { + f.mu.Unlock() + run.cancel() + close(run.done) + return err + } + f.activeRun = run + f.mu.Unlock() + + f.launchWorkers(run) + go f.supervise(run) + return nil +} + +// Pause stops all segment downloads synchronously. It waits for the queue +// producer, every worker AND the supervisor (including a merge in flight) +// before returning, so a Store right after Pause sees a consistent state. +func (f *Fetcher) Pause() error { + f.mu.Lock() + run := f.activeRun + f.mu.Unlock() + if run == nil { + return nil + } + run.cancel() + <-run.done + f.clearActiveRun(run) + return nil +} + +// Close pauses any active run and only removes the staging folder once every +// run goroutine has stopped writing to it. +func (f *Fetcher) Close() error { + f.mu.Lock() + f.closed = true + run := f.activeRun + tempDir := "" + if f.state != nil { + tempDir = f.resolveStagingDirLocked() + } + f.mu.Unlock() + + if run != nil { + run.cancel() + <-run.done + f.clearActiveRun(run) + } + if f.impSession != nil { + f.impSession.Clear() + } + if tempDir != "" { + os.RemoveAll(tempDir) + } + return nil +} + +// clearActiveRun detaches a finished run and keeps its counters as the last +// known values for idle Stats/Progress reporting. +func (f *Fetcher) clearActiveRun(run *hlsRun) { + f.mu.Lock() + if f.activeRun == run { + f.activeRun = nil + f.lastStats = run.statsSnapshot() + f.lastDownloaded = run.downloaded.Load() + } + f.mu.Unlock() +} + +// Progress reports final-output bytes stored on disk for the single file. +func (f *Fetcher) Progress() fetcher.Progress { + f.mu.Lock() + run := f.activeRun + if run != nil { + f.mu.Unlock() + return fetcher.Progress{run.downloaded.Load()} + } + last := f.lastDownloaded + f.mu.Unlock() + return fetcher.Progress{last} +} + +func (f *Fetcher) Stats() *fetcher.Stats { + f.mu.Lock() + run := f.activeRun + state := f.state + if run != nil { + f.mu.Unlock() + snap := run.statsSnapshot() + return &fetcher.Stats{Snapshot: &snap} + } + snap := f.lastStats + f.mu.Unlock() + if snap.SegmentsTotal == 0 && state != nil { + snap.SegmentsTotal = len(state.Segments) + } + return &fetcher.Stats{Snapshot: &snap} +} + +// supervise waits for the segment workers, then merges. A canceled context +// exits silently (the engine is pausing), everything else is reported through +// DoneCh exactly once per run. +func (f *Fetcher) supervise(run *hlsRun) { + run.wg.Wait() + defer close(run.done) + + if run.ctx.Err() != nil { + f.clearActiveRun(run) + return + } + var err error + if failed := run.failCount.Load(); failed > 0 { + err = fmt.Errorf("%d segment(s) failed to download", failed) + } else { + err = f.merge(run) + if err == nil { + if run.ctx.Err() != nil { + // Paused while the merge was in flight: keep the staged + // segments and exit silently; the next run re-merges. + f.clearActiveRun(run) + return + } + os.RemoveAll(run.tempDir) + } + } + f.clearActiveRun(run) + select { + case f.DoneCh <- err: + default: + } +} + +// prepareRun prepares the staging folder and loads the completed-segment +// journal into run.baseBytes/baseDone/pending. It performs no goroutine +// launches and no context-bound work, so it runs safely under f.mu before +// the run is published. +func (f *Fetcher) prepareRun(run *hlsRun) error { + if err := os.MkdirAll(run.tempDir, 0777); err != nil { + return err + } + run.journalPath = filepath.Join(run.tempDir, "journal.json") + if err := loadJournal(run); err != nil { + return err + } + for i := range run.segs { + if size, ok := run.journal[int64(i)]; ok { + if info, err := os.Stat(segmentPath(run.tempDir, int64(i))); err == nil && info.Size() == size { + run.baseBytes += size + run.baseDone++ + continue + } + } + run.pending = append(run.pending, int64(i)) + } + run.downloaded.Store(run.baseBytes) + return nil +} + +// launchWorkers starts the queue producer and the segment download workers +// for a published run. Every goroutine registers itself in run.wg so Pause +// waits for all of them. +func (f *Fetcher) launchWorkers(run *hlsRun) { + if len(run.pending) == 0 { + return + } + queue := make(chan int64) + run.wg.Add(1) + go func() { + defer run.wg.Done() + defer close(queue) + for _, idx := range run.pending { + select { + case <-run.ctx.Done(): + return + case queue <- idx: + } + } + }() + + workers := min(f.config.SegmentConnections, len(run.pending)) + for i := 0; i < workers; i++ { + run.wg.Add(1) + go func() { + defer run.wg.Done() + for { + select { + case <-run.ctx.Done(): + return + case idx, ok := <-queue: + if !ok { + return + } + if err := f.downloadSegment(run, idx); err != nil { + if errors.Is(err, context.Canceled) { + return + } + run.failCount.Add(1) + } + } + } + }() + } +} + +func (f *Fetcher) downloadSegment(run *hlsRun, idx int64) error { + seg := run.segs[idx] + data, err := f.fetchSegmentData(run, seg) + if err != nil { + return err + } + if seg.Key != nil { + key, err := f.fetchKey(run, seg.Key) + if err != nil { + return err + } + iv := seg.Key.IV + if iv == nil { + iv = sequenceIV(seg.Sequence) + } + data, err = decryptAES128(data, key, iv) + if err != nil { + return err + } + } + if err := storeSegment(run, idx, data); err != nil { + return err + } + run.doneCount.Add(1) + run.downloaded.Add(int64(len(data))) + return nil +} + +// fetchSegmentData downloads one segment with retries, returning its (still +// encrypted) content. +func (f *Fetcher) fetchSegmentData(run *hlsRun, seg *Segment) ([]byte, error) { + var lastErr error + for attempt := 0; attempt <= f.config.MaxRetries; attempt++ { + if err := run.ctx.Err(); err != nil { + return nil, err + } + if attempt > 0 { + select { + case <-run.ctx.Done(): + return nil, run.ctx.Err() + case <-time.After(time.Duration(attempt) * time.Second): + } + } + data, err := f.tryGetSegment(run, seg) + if err == nil { + return data, nil + } + if errors.Is(err, context.Canceled) { + return nil, err + } + lastErr = err + } + return nil, lastErr +} + +func (f *Fetcher) tryGetSegment(run *hlsRun, seg *Segment) ([]byte, error) { + resp, err := f.do(run.ctx, run.client, run.extra, http.MethodGet, seg.URI, seg.Byterange) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("segment %s: unexpected status %d", seg.URI, resp.StatusCode) + } + reader := &stallReader{rc: resp.Body, timeout: f.timeout()} + var buf bytes.Buffer + chunk := make([]byte, 64<<10) + for { + n, err := reader.Read(chunk) + if n > 0 { + buf.Write(chunk[:n]) + } + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, err + } + } + if seg.Byterange != nil && int64(buf.Len()) != seg.Byterange.Length { + return nil, fmt.Errorf("segment %s: size mismatch, got %d want %d", seg.URI, buf.Len(), seg.Byterange.Length) + } + return buf.Bytes(), nil +} + +func (f *Fetcher) fetchKey(run *hlsRun, key *Key) ([]byte, error) { + run.keyCacheMu.Lock() + if cached, ok := run.keyCache[key.URI]; ok { + run.keyCacheMu.Unlock() + return cached, nil + } + run.keyCacheMu.Unlock() + + var data []byte + var err error + for attempt := 0; attempt <= f.config.MaxRetries; attempt++ { + if err = run.ctx.Err(); err != nil { + return nil, err + } + if attempt > 0 { + select { + case <-run.ctx.Done(): + return nil, run.ctx.Err() + case <-time.After(time.Duration(attempt) * time.Second): + } + } + resp, reqErr := f.do(run.ctx, run.client, run.extra, http.MethodGet, key.URI, nil) + if reqErr != nil { + err = reqErr + continue + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + resp.Body.Close() + err = fmt.Errorf("key %s: unexpected status %d", key.URI, resp.StatusCode) + continue + } + data, err = io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + resp.Body.Close() + if err == nil { + break + } + } + if err != nil { + return nil, err + } + if len(data) != 16 { + return nil, fmt.Errorf("key %s: invalid key size %d", key.URI, len(data)) + } + run.keyCacheMu.Lock() + run.keyCache[key.URI] = data + run.keyCacheMu.Unlock() + return data, nil +} + +// merge concatenates the downloaded segments in plan order into the final +// output file. Segments are stored decrypted, so this is pure I/O. The merge +// writes to a unique temporary file next to the target, checks write and +// close errors, and only then replaces the target: a failed merge leaves the +// staged segments intact so it can be retried, and the unique part name keeps +// concurrent tasks that share an output path from clobbering each other. The +// final size is published through Progress: the engine backfills Res.Size +// from Progress().TotalDownloaded() on completion. +func (f *Fetcher) merge(run *hlsRun) error { + target := run.outputPath + if err := os.MkdirAll(filepath.Dir(target), 0777); err != nil { + return err + } + partPath := target + tempDirPrefix + run.stagingID + ".part" + out, err := os.Create(partPath) + if err != nil { + return err + } + var total int64 + for i := range run.segs { + data, err := os.ReadFile(segmentPath(run.tempDir, int64(i))) + if err != nil { + out.Close() + os.Remove(partPath) + return fmt.Errorf("read segment %d failed: %w", i, err) + } + if _, err := out.Write(data); err != nil { + out.Close() + os.Remove(partPath) + return err + } + total += int64(len(data)) + } + if err := out.Close(); err != nil { + os.Remove(partPath) + return err + } + // Remove-then-rename keeps this idempotent: if a previous merge already + // renamed its output before a crash lost the bookkeeping, re-merging + // must still succeed. + if err := os.Remove(target); err != nil && !os.IsNotExist(err) { + os.Remove(partPath) + return err + } + if err := os.Rename(partPath, target); err != nil { + os.Remove(partPath) + return err + } + run.downloaded.Store(total) + return nil +} + +func storeSegment(run *hlsRun, idx int64, data []byte) error { + partPath := segmentPath(run.tempDir, idx) + ".part" + if err := os.WriteFile(partPath, data, 0666); err != nil { + return err + } + if err := os.Rename(partPath, segmentPath(run.tempDir, idx)); err != nil { + return err + } + run.journalMu.Lock() + defer run.journalMu.Unlock() + run.journal[idx] = int64(len(data)) + return saveJournal(run) +} + +func segmentPath(tempDir string, idx int64) string { + return filepath.Join(tempDir, "seg-"+strconv.FormatInt(idx, 10)) +} + +// resolveStagingDirLocked returns the staging folder of the current plan, +// deriving and pinning it on first use so later output renames or moves do +// not orphan the staged segments. It lives next to the task output, which is +// exactly opts.Path for a single-file task. Must be called with f.mu held. +func (f *Fetcher) resolveStagingDirLocked() string { + if f.state.StagingDir == "" { + if f.state.StagingID == "" { + // Legacy state (or a hand-crafted plan): migrate to a fresh task + // owned identity. The old URL-hash folder is deliberately not + // reused: its contents cannot be proven to belong to this plan, + // and sharing it with another same-URL task is exactly the bug + // being fixed. It is left behind as orphaned temp data. + f.state.StagingID = randomStagingID() + } + dir := "" + if f.meta.Opts != nil { + dir = f.meta.Opts.Path + } + f.state.StagingDir = filepath.Join(dir, tempDirPrefix+f.state.StagingID) + } + return f.state.StagingDir +} + +// prefetchSize probes segment Content-Length via HEAD. Any failure resets all +// sizes to zero: a partial size estimate would corrupt progress reporting. +func (f *Fetcher) prefetchSize(segments []*Segment) { + var need []*Segment + for _, seg := range segments { + if seg.Size <= 0 { + need = append(need, seg) + } + } + if len(need) == 0 || len(need) > maxPrefetchSizeSegments { + return + } + + workerCount := min(f.config.SegmentConnections, 8) + var failed atomic.Bool + queue := make(chan *Segment) + var wg sync.WaitGroup + for i := 0; i < workerCount; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for seg := range queue { + ctx, cancel := context.WithTimeout(context.Background(), f.timeout()) + seg.Size = f.headContentLength(ctx, seg.URI) + cancel() + if seg.Size <= 0 { + failed.Store(true) + } + } + }() + } + for _, seg := range need { + queue <- seg + } + close(queue) + wg.Wait() + if failed.Load() { + for _, seg := range segments { + seg.Size = 0 + } + } +} + +func (f *Fetcher) headContentLength(ctx context.Context, u string) int64 { + req, err := buildRequest(ctx, f.extra, http.MethodHead, u, nil, nil) + if err != nil { + return 0 + } + resp, err := f.client.Do(req) + if err != nil { + return 0 + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return 0 + } + return resp.ContentLength +} + +func (f *Fetcher) fetchText(ctx context.Context, method, u string) (string, string, error) { + resp, err := f.do(ctx, f.client, f.extra, method, u, nil) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", "", fmt.Errorf("unexpected status %d for %s", resp.StatusCode, u) + } + data, err := io.ReadAll(io.LimitReader(&stallReader{rc: resp.Body, timeout: f.timeout()}, maxPlaylistSize)) + if err != nil { + return "", "", err + } + return string(data), resp.Request.URL.String(), nil +} + +func (f *Fetcher) do(ctx context.Context, client *http.Client, extra *ReqExtra, method, u string, byterange *ByteRange) (*http.Response, error) { + if method == "" { + method = http.MethodGet + } + req, err := buildRequest(ctx, extra, method, u, nil, byterange) + if err != nil { + return nil, err + } + return client.Do(req) +} + +func (f *Fetcher) playlistMethod() string { + if f.extra != nil && f.extra.Method != "" { + return f.extra.Method + } + return http.MethodGet +} + +// planTotalSize sums the known segment sizes. It returns 0 unless every +// segment is unencrypted with a known size: ciphertext lengths overstate the +// final output (CBC padding is removed on decrypt), and partial estimates +// would make progress stall below 100%. For indeterminate plans the engine +// backfills Res.Size from Progress().TotalDownloaded() when the task +// completes (downloader.go watch). +func planTotalSize(segments []*Segment) int64 { + var total int64 + for _, seg := range segments { + if seg.Key != nil { + return 0 + } + if seg.Size <= 0 { + return 0 + } + total += seg.Size + } + return total +} diff --git a/internal/protocol/hls/fetcher_manager.go b/internal/protocol/hls/fetcher_manager.go new file mode 100644 index 00000000..63941ec2 --- /dev/null +++ b/internal/protocol/hls/fetcher_manager.go @@ -0,0 +1,100 @@ +package hls + +import ( + "net/url" + "path" + "strings" + + "github.com/GopeedLab/gopeed/internal/fetcher" +) + +type FetcherManager struct { +} + +func (fm *FetcherManager) Name() string { + return "hls" +} + +// Filters registers the .m3u8 extension. The manager must be registered BEFORE +// the http manager in the downloader config: protocol routing picks the first +// manager whose filter matches, and the http filter claims every http(s) URL. +func (fm *FetcherManager) Filters() []*fetcher.SchemeFilter { + return []*fetcher.SchemeFilter{ + { + Type: fetcher.FilterTypeFile, + Pattern: "M3U8", + }, + } +} + +func (fm *FetcherManager) Build() fetcher.Fetcher { + return &Fetcher{} +} + +func (fm *FetcherManager) ParseName(u string) string { + parsed, err := url.Parse(u) + if err != nil { + return "" + } + name := path.Base(parsed.Path) + name = strings.TrimSuffix(name, path.Ext(name)) + if name == "" || name == "/" || name == "." { + name = parsed.Hostname() + } + return name +} + +func (fm *FetcherManager) AutoRename() bool { + return true +} + +func (fm *FetcherManager) DefaultConfig() any { + return &config{ + SegmentConnections: defaultSegmentConnections, + MaxRetries: defaultMaxRetries, + TimeoutSeconds: defaultTimeoutSeconds, + PrefetchContentLength: true, + } +} + +func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) { + _f := f.(*Fetcher) + _f.mu.Lock() + defer _f.mu.Unlock() + if _f.state == nil { + return nil, nil + } + // The engine's periodic checkpoint calls Store without holding the task + // lock and serializes the returned value afterwards, while Resolve/Patch/ + // Start mutate the live state under the fetcher mutex: return a snapshot + // copy taken under that mutex, mirroring the HTTP protocol's Store. + return cloneState(_f.state), nil +} + +func cloneState(state *fetcherState) *fetcherState { + cloned := *state + cloned.Segments = make([]*Segment, len(state.Segments)) + for i, seg := range state.Segments { + s := *seg + cloned.Segments[i] = &s + } + return &cloned +} + +func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) { + return &fetcherState{}, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher { + fb := &FetcherManager{} + restored := fb.Build().(*Fetcher) + restored.meta = meta + if state, ok := v.(*fetcherState); ok && state.MediaURL != "" { + restored.state = state + } + // Setup is invoked by the engine (restoreFetcher) after this builder + // returns; ReqExtra parsing happens there as well. + return restored + } +} + +func (fm *FetcherManager) Close() error { + return nil +} diff --git a/internal/protocol/hls/fetcher_test.go b/internal/protocol/hls/fetcher_test.go new file mode 100644 index 00000000..3dd785f0 --- /dev/null +++ b/internal/protocol/hls/fetcher_test.go @@ -0,0 +1,857 @@ +package hls + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/GopeedLab/gopeed/internal/controller" + "github.com/GopeedLab/gopeed/internal/fetcher" + "github.com/GopeedLab/gopeed/pkg/base" +) + +func pkcs7PadForTest(data []byte, blockSize int) []byte { + pad := blockSize - len(data)%blockSize + return append(data, bytes.Repeat([]byte{byte(pad)}, pad)...) +} + +func encryptSegment(plain, key, iv []byte) []byte { + padded := pkcs7PadForTest(plain, aes.BlockSize) + block, _ := aes.NewCipher(key) + encrypted := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(encrypted, padded) + return encrypted +} + +// serveContentWithReferer is a helper handler that enforces an optional +// Referer requirement and reports request counts. +type cdnHandler struct { + mux *http.ServeMux + requireReferer string + requests atomic.Int64 +} + +func (h *cdnHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.requests.Add(1) + if h.requireReferer != "" && r.Header.Get("Referer") != h.requireReferer { + w.WriteHeader(http.StatusForbidden) + return + } + h.mux.ServeHTTP(w, r) +} + +func newTestFetcher(t *testing.T, cfg config) *Fetcher { + t.Helper() + fm := &FetcherManager{} + f := fm.Build().(*Fetcher) + ctl := controller.NewController() + ctl.GetConfig = func(v any) { + if c, ok := v.(*config); ok { + *c = cfg + } + } + f.Setup(ctl) + return f +} + +func testConfig() config { + return config{ + SegmentConnections: 4, + MaxRetries: 2, + TimeoutSeconds: 5, + PrefetchContentLength: true, + } +} + +func mustResolve(t *testing.T, f *Fetcher, playlistURL, referer string) { + t.Helper() + req := &base.Request{URL: playlistURL} + if referer != "" { + req.Extra = &ReqExtra{Header: map[string]string{"Referer": referer}} + } + if err := f.Resolve(req, &base.Options{Path: t.TempDir()}); err != nil { + t.Fatalf("resolve: %v", err) + } +} + +func mustRun(t *testing.T, f *Fetcher) { + t.Helper() + if err := f.Start(); err != nil { + t.Fatalf("start: %v", err) + } + if err := f.Wait(); err != nil { + t.Fatalf("wait: %v", err) + } +} + +func statsOf(t *testing.T, f *Fetcher) Stats { + t.Helper() + s, ok := f.Stats().Snapshot.(*Stats) + if !ok { + t.Fatalf("unexpected stats snapshot type %T", f.Stats().Snapshot) + } + return *s +} + +func readOutput(t *testing.T, f *Fetcher) []byte { + t.Helper() + data, err := os.ReadFile(f.meta.SingleFilepath()) + if err != nil { + t.Fatal(err) + } + return data +} + +func TestFetcherE2E_MasterTS(t *testing.T) { + seg := func(i int) []byte { + return []byte(fmt.Sprintf("SEGMENT-%02d-PAYLOAD", i)) + } + mux := http.NewServeMux() + mux.HandleFunc("/master.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("#EXTM3U\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=800000\nv1/index.m3u8\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=2000000\nv2/index.m3u8\n")) + }) + mux.HandleFunc("/v1/index.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("#EXTM3U\n#EXTINF:4,\nlow-0.ts\n#EXT-X-ENDLIST\n")) + }) + mux.HandleFunc("/v2/index.m3u8", func(w http.ResponseWriter, r *http.Request) { + var b strings.Builder + b.WriteString("#EXTM3U\n") + for i := 0; i < 5; i++ { + b.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i)) + } + b.WriteString("#EXT-X-ENDLIST\n") + w.Write([]byte(b.String())) + }) + for i := 0; i < 5; i++ { + content := seg(i) + path := "/v2/seg-" + strconv.Itoa(i) + ".ts" + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", strconv.Itoa(len(content))) + w.Write(content) + }) + } + server := httptest.NewServer(&cdnHandler{mux: mux}) + defer server.Close() + + f := newTestFetcher(t, testConfig()) + mustResolve(t, f, server.URL+"/master.m3u8", "") + // Master resolution picked the 2000000 variant. + if f.state.MediaURL != server.URL+"/v2/index.m3u8" { + t.Fatalf("wrong variant picked: %s", f.state.MediaURL) + } + // HEAD prefetch should have computed the exact total size. + if f.meta.Res.Size != int64(5*len(seg(0))) { + t.Fatalf("prefetched size wrong: %d", f.meta.Res.Size) + } + mustRun(t, f) + + var want []byte + for i := 0; i < 5; i++ { + want = append(want, seg(i)...) + } + got := readOutput(t, f) + if !bytes.Equal(got, want) { + t.Errorf("merged output mismatch: %q", got) + } + // The staging folder must be cleaned up after a successful merge; only + // the output file remains in the task directory. + if entries, _ := os.ReadDir(filepath.Dir(f.meta.SingleFilepath())); len(entries) != 1 { + for _, e := range entries { + t.Logf("leftover entry: %s", e.Name()) + } + t.Errorf("staging dir should be cleaned up after merge") + } + if f.meta.Res.Size != int64(len(want)) { + t.Errorf("final size wrong: %d", f.meta.Res.Size) + } +} + +func TestFetcherE2E_RefererEnforced(t *testing.T) { + page := "https://player.example.com/watch/123" + var playlist strings.Builder + playlist.WriteString("#EXTM3U\n") + segments := make(map[string][]byte) + for i := 0; i < 3; i++ { + name := fmt.Sprintf("seg-%d.ts", i) + segments["/"+name] = []byte(fmt.Sprintf("DATA-%d", i)) + playlist.WriteString(fmt.Sprintf("#EXTINF:4,\n%s\n", name)) + } + playlist.WriteString("#EXT-X-ENDLIST\n") + + mux := http.NewServeMux() + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(playlist.String())) + }) + for path, content := range segments { + c := content + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + w.Write(c) + }) + } + server := httptest.NewServer(&cdnHandler{mux: mux, requireReferer: page}) + defer server.Close() + + // Without Referer the resolve itself must fail (403 on the playlist). + blocked := newTestFetcher(t, testConfig()) + req := &base.Request{URL: server.URL + "/video.m3u8"} + if err := blocked.Resolve(req, &base.Options{Path: t.TempDir()}); err == nil { + t.Fatal("resolve without Referer should fail") + } + + // With the Referer set through the task extra headers it must succeed. + allowed := newTestFetcher(t, testConfig()) + allowedReq := &base.Request{ + URL: server.URL + "/video.m3u8", + Extra: &ReqExtra{Header: map[string]string{"Referer": page}}, + } + if err := allowed.Resolve(allowedReq, &base.Options{Path: t.TempDir()}); err != nil { + t.Fatal(err) + } + mustRun(t, allowed) + want := []byte("DATA-0DATA-1DATA-2") + if !bytes.Equal(readOutput(t, allowed), want) { + t.Error("output mismatch with Referer download") + } +} + +func TestFetcherE2E_AES128KeyRotation(t *testing.T) { + key1 := []byte("0123456789abcdef") + key2 := []byte("fedcba9876543210") + iv1 := []byte("aaaaaaaaaaaaaaaa") + plain1 := []byte("ENCRYPTED-SEGMENT-ONE!!") // padded on encrypt + plain2 := []byte("ENCRYPTED-SEGMENT-TWO!!") + // seg-2 has no explicit IV: derived from media sequence 1. + enc1 := encryptSegment(plain1, key1, iv1) + enc2 := encryptSegment(plain2, key2, sequenceIV(1)) + + mux := http.NewServeMux() + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("#EXTM3U\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXT-X-KEY:METHOD=AES-128,URI=\"key-1.bin\",IV=0x" + hexEncode(iv1) + "\n" + + "#EXTINF:5,\nseg-1.ts\n" + + "#EXT-X-KEY:METHOD=AES-128,URI=\"key-2.bin\"\n" + + "#EXTINF:5,\nseg-2.ts\n" + + "#EXT-X-ENDLIST\n")) + }) + mux.HandleFunc("/key-1.bin", func(w http.ResponseWriter, r *http.Request) { + w.Write(key1) + }) + mux.HandleFunc("/key-2.bin", func(w http.ResponseWriter, r *http.Request) { + w.Write(key2) + }) + mux.HandleFunc("/seg-1.ts", func(w http.ResponseWriter, r *http.Request) { + w.Write(enc1) + }) + mux.HandleFunc("/seg-2.ts", func(w http.ResponseWriter, r *http.Request) { + w.Write(enc2) + }) + server := httptest.NewServer(mux) + defer server.Close() + + f := newTestFetcher(t, testConfig()) + mustResolve(t, f, server.URL+"/video.m3u8", "") + // Ciphertext sizes overstate the final output after decryption, so an + // encrypted plan must report an indeterminate size. + if f.meta.Res.Size != 0 { + t.Errorf("encrypted plan should report size 0, got %d", f.meta.Res.Size) + } + mustRun(t, f) + + want := append(append([]byte{}, plain1...), plain2...) + if !bytes.Equal(readOutput(t, f), want) { + t.Errorf("decrypted output mismatch: %q", readOutput(t, f)) + } +} + +func TestFetcherE2E_ByteRange(t *testing.T) { + data := []byte("0123456789ABCDEFGHIJ") // 20 bytes + mux := http.NewServeMux() + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("#EXTM3U\n" + + "#EXT-X-BYTERANGE:10@0\nblob.bin\n" + + "#EXT-X-BYTERANGE:10\nblob.bin\n" + + "#EXT-X-ENDLIST\n")) + }) + mux.HandleFunc("/blob.bin", func(w http.ResponseWriter, r *http.Request) { + rng := r.Header.Get("Range") + if rng == "" { + w.Write(data) + return + } + // bytes=start-end + var start, end int + fmt.Sscanf(rng, "bytes=%d-%d", &start, &end) + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(data))) + w.WriteHeader(http.StatusPartialContent) + w.Write(data[start : end+1]) + }) + server := httptest.NewServer(mux) + defer server.Close() + + f := newTestFetcher(t, testConfig()) + mustResolve(t, f, server.URL+"/video.m3u8", "") + if f.meta.Res.Size != 20 { + t.Fatalf("byterange sizes should be known from the playlist, got %d", f.meta.Res.Size) + } + mustRun(t, f) + if !bytes.Equal(readOutput(t, f), data) { + t.Errorf("byterange merge mismatch: %q", readOutput(t, f)) + } +} + +func TestFetcherE2E_PauseResume(t *testing.T) { + const total = 8 + var served atomic.Int64 + mux := http.NewServeMux() + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + var b strings.Builder + b.WriteString("#EXTM3U\n") + for i := 0; i < total; i++ { + b.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i)) + } + b.WriteString("#EXT-X-ENDLIST\n") + w.Write([]byte(b.String())) + }) + segments := make(map[int][]byte) + for i := 0; i < total; i++ { + segments[i] = []byte(fmt.Sprintf("SLOW-SEGMENT-%02d", i)) + } + for i := 0; i < total; i++ { + idx := i + mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", i), func(w http.ResponseWriter, r *http.Request) { + time.Sleep(150 * time.Millisecond) + served.Add(1) + w.Write(segments[idx]) + }) + } + server := httptest.NewServer(mux) + defer server.Close() + + cfg := testConfig() + cfg.SegmentConnections = 2 + cfg.PrefetchContentLength = false + f := newTestFetcher(t, cfg) + mustResolve(t, f, server.URL+"/video.m3u8", "") + + if err := f.Start(); err != nil { + t.Fatal(err) + } + // Let a couple of segments finish, then pause. + time.Sleep(400 * time.Millisecond) + if err := f.Pause(); err != nil { + t.Fatal(err) + } + doneBefore := statsOf(t, f).SegmentsDone + if doneBefore == 0 { + t.Fatal("expected some segments to complete before pause") + } + if doneBefore == total { + t.Fatal("all segments finished before pause, test is not exercising resume") + } + + // Resume must finish the rest and produce the full output. + if err := f.Start(); err != nil { + t.Fatal(err) + } + if err := f.Wait(); err != nil { + t.Fatal(err) + } + var want []byte + for i := 0; i < total; i++ { + want = append(want, segments[i]...) + } + if !bytes.Equal(readOutput(t, f), want) { + t.Errorf("resumed output mismatch: %q", readOutput(t, f)) + } +} + +func TestFetcherE2E_StoreRestoreResume(t *testing.T) { + const total = 6 + var served atomic.Int64 + mux := http.NewServeMux() + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + var b strings.Builder + b.WriteString("#EXTM3U\n") + for i := 0; i < total; i++ { + b.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i)) + } + b.WriteString("#EXT-X-ENDLIST\n") + w.Write([]byte(b.String())) + }) + segments := make(map[int][]byte) + for i := 0; i < total; i++ { + segments[i] = []byte(fmt.Sprintf("RESTORE-SEGMENT-%02d", i)) + } + for i := 0; i < total; i++ { + idx := i + mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", i), func(w http.ResponseWriter, r *http.Request) { + time.Sleep(120 * time.Millisecond) + served.Add(1) + w.Write(segments[idx]) + }) + } + server := httptest.NewServer(mux) + defer server.Close() + + cfg := testConfig() + cfg.SegmentConnections = 2 + cfg.PrefetchContentLength = false + + f := newTestFetcher(t, cfg) + mustResolve(t, f, server.URL+"/video.m3u8", "") + if err := f.Start(); err != nil { + t.Fatal(err) + } + time.Sleep(350 * time.Millisecond) + if err := f.Pause(); err != nil { + t.Fatal(err) + } + doneBefore := statsOf(t, f).SegmentsDone + if doneBefore == 0 { + t.Fatal("expected some segments to finish before simulated restart") + } + + // Simulate an app restart: persist, then restore into a fresh fetcher. + fm := &FetcherManager{} + state, err := fm.Store(f) + if err != nil { + t.Fatal(err) + } + if state == nil { + t.Fatal("state should be persisted") + } + ctl := controller.NewController() + ctl.GetConfig = func(v any) { + if c, ok := v.(*config); ok { + *c = cfg + } + } + holder := fm.Build().(*Fetcher) + holder.Setup(ctl) + holder.meta = &fetcher.FetcherMeta{ + Req: f.meta.Req, + Opts: f.meta.Opts, + Res: f.meta.Res, + } + _, builder := fm.Restore() + rf := builder(holder.meta, state).(*Fetcher) + // The engine calls Setup on the builder result (restoreFetcher). + rf.Setup(ctl) + if rf.state == nil || len(rf.state.Segments) != total { + t.Fatal("restored state missing segment plan") + } + + if err := rf.Start(); err != nil { + t.Fatal(err) + } + if err := rf.Wait(); err != nil { + t.Fatal(err) + } + if done := statsOf(t, rf).SegmentsDone; done != total { + t.Errorf("want %d done, got %d", total, done) + } + var want []byte + for i := 0; i < total; i++ { + want = append(want, segments[i]...) + } + if !bytes.Equal(readOutput(t, rf), want) { + t.Errorf("restored output mismatch: %q", readOutput(t, rf)) + } +} + +func hexEncode(b []byte) string { + const digits = "0123456789abcdef" + out := make([]byte, 0, len(b)*2) + for _, v := range b { + out = append(out, digits[v>>4], digits[v&0x0f]) + } + return string(out) +} + +// restoreLikeEngine round-trips the persisted state and the task meta through +// JSON the way the engine storage does, then builds a fresh fetcher. This is +// the path that historically lost the request extra headers. +func restoreLikeEngine(t *testing.T, f *Fetcher, cfg config) *Fetcher { + t.Helper() + fm := &FetcherManager{} + stateAny, err := fm.Store(f) + if err != nil { + t.Fatal(err) + } + rawState, err := json.Marshal(stateAny) + if err != nil { + t.Fatal(err) + } + storedState := &fetcherState{} + if err := json.Unmarshal(rawState, storedState); err != nil { + t.Fatal(err) + } + rawMeta, err := json.Marshal(f.meta) + if err != nil { + t.Fatal(err) + } + restoredMeta := &fetcher.FetcherMeta{} + if err := json.Unmarshal(rawMeta, restoredMeta); err != nil { + t.Fatal(err) + } + if _, ok := restoredMeta.Req.Extra.(*ReqExtra); ok { + t.Fatal("storage round trip should yield a map-shaped extra, like the engine") + } + ctl := controller.NewController() + ctl.GetConfig = func(v any) { + if c, ok := v.(*config); ok { + *c = cfg + } + } + _, builder := fm.Restore() + rf := builder(restoredMeta, storedState).(*Fetcher) + // The engine calls Setup on the builder result (restoreFetcher). + rf.Setup(ctl) + return rf +} + +func TestFetcherE2E_StoreRestoreAuthenticated(t *testing.T) { + const total = 4 + page := "https://player.example.com/watch/42" + var served atomic.Int64 + mux := http.NewServeMux() + var playlist strings.Builder + playlist.WriteString("#EXTM3U\n") + for i := 0; i < total; i++ { + playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i)) + } + playlist.WriteString("#EXT-X-ENDLIST\n") + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(playlist.String())) + }) + segments := make(map[int][]byte) + for i := 0; i < total; i++ { + segments[i] = []byte(fmt.Sprintf("AUTH-SEGMENT-%02d", i)) + idx := i + mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", idx), func(w http.ResponseWriter, r *http.Request) { + time.Sleep(80 * time.Millisecond) + served.Add(1) + w.Write(segments[idx]) + }) + } + server := httptest.NewServer(&cdnHandler{mux: mux, requireReferer: page}) + defer server.Close() + + cfg := testConfig() + cfg.SegmentConnections = 2 + cfg.PrefetchContentLength = false + + f := newTestFetcher(t, cfg) + req := &base.Request{ + URL: server.URL + "/video.m3u8", + Extra: &ReqExtra{Header: map[string]string{"Referer": page}}, + } + if err := f.Resolve(req, &base.Options{Path: t.TempDir()}); err != nil { + t.Fatal(err) + } + if err := f.Start(); err != nil { + t.Fatal(err) + } + time.Sleep(150 * time.Millisecond) + if err := f.Pause(); err != nil { + t.Fatal(err) + } + + rf := restoreLikeEngine(t, f, cfg) + if rf.extra == nil || rf.extra.Header["Referer"] != page { + t.Fatalf("restored fetcher lost the request extra headers: %+v", rf.extra) + } + if err := rf.Start(); err != nil { + t.Fatal(err) + } + if err := rf.Wait(); err != nil { + t.Fatalf("resumed download failed (request headers lost?): %v", err) + } + var want []byte + for i := 0; i < total; i++ { + want = append(want, segments[i]...) + } + if !bytes.Equal(readOutput(t, rf), want) { + t.Errorf("restored output mismatch: %q", readOutput(t, rf)) + } +} + +func TestFetcherE2E_PatchPartialUpdate(t *testing.T) { + page := "https://player.example.com/watch/7" + var requiredReferer atomic.Value // string + requiredReferer.Store(page) + var playlist strings.Builder + playlist.WriteString("#EXTM3U\n") + segments := make(map[string][]byte) + for i := 0; i < 3; i++ { + segments[fmt.Sprintf("/s%d.ts", i)] = []byte(fmt.Sprintf("PATCH-%d", i)) + playlist.WriteString(fmt.Sprintf("#EXTINF:4,\ns%d.ts\n", i)) + } + playlist.WriteString("#EXT-X-ENDLIST\n") + mux := http.NewServeMux() + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(playlist.String())) + }) + for path, content := range segments { + c := content + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + w.Write(c) + }) + } + // The Referer requirement can be flipped mid-test to prove that a patched + // header is actually sent by the next run. + dyn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if want, _ := requiredReferer.Load().(string); want != "" && r.Header.Get("Referer") != want { + w.WriteHeader(http.StatusForbidden) + return + } + mux.ServeHTTP(w, r) + }) + server := httptest.NewServer(dyn) + defer server.Close() + + f := newTestFetcher(t, testConfig()) + req := &base.Request{ + URL: server.URL + "/video.m3u8", + Extra: &ReqExtra{Header: map[string]string{"Referer": page}}, + } + opts := &base.Options{Path: t.TempDir()} + if err := f.Resolve(req, opts); err != nil { + t.Fatal(err) + } + + // A nil/nil patch must be a no-op, not a panic. + if err := f.Patch(nil, nil); err != nil { + t.Fatalf("nil patch should be accepted: %v", err) + } + + // Header-only patch: the resolved plan and output options are retained + // while the new header takes effect on the next run. + newReferer := "https://player.example.com/watch/9" + if err := f.Patch(&base.Request{ + URL: server.URL + "/video.m3u8", + Extra: &ReqExtra{Header: map[string]string{"Referer": newReferer}}, + }, nil); err != nil { + t.Fatal(err) + } + if f.state == nil || f.meta.Res == nil { + t.Fatal("header-only patch must keep the resolved plan") + } + if f.meta.Opts.Path != opts.Path { + t.Fatalf("header-only patch must keep the output path: %s", f.meta.Opts.Path) + } + if f.extra.Header["Referer"] != newReferer { + t.Fatalf("patched header not applied: %+v", f.extra.Header) + } + requiredReferer.Store(newReferer) + if err := f.Start(); err != nil { + t.Fatal(err) + } + if err := f.Wait(); err != nil { + t.Fatalf("run with patched header failed: %v", err) + } + if got := readOutput(t, f); string(got) != "PATCH-0PATCH-1PATCH-2" { + t.Errorf("output mismatch after header patch: %q", got) + } + + // Options-only patch: renaming the task keeps the resolved plan. + if err := f.Patch(nil, &base.Options{Name: "renamed.ts"}); err != nil { + t.Fatal(err) + } + if f.state == nil { + t.Fatal("options-only patch must keep the resolved plan") + } + if f.meta.Opts.Name != "renamed.ts" || f.meta.Opts.Path != opts.Path { + t.Fatalf("options patch misapplied: %+v", f.meta.Opts) + } + + // URL-only patch: the plan is dropped for a re-resolve but the output + // path and renamed file survive. + if err := f.Patch(&base.Request{URL: server.URL + "/refreshed.m3u8"}, nil); err != nil { + t.Fatal(err) + } + if f.state != nil || f.meta.Res != nil { + t.Fatal("URL patch must drop the resolved plan") + } + if f.meta.Opts.Path != opts.Path || f.meta.Opts.Name != "renamed.ts" { + t.Fatal("URL patch must keep the output options") + } +} + +func TestFetcherE2E_PatchWhileRunningRejected(t *testing.T) { + mux := http.NewServeMux() + var playlist strings.Builder + playlist.WriteString("#EXTM3U\n") + for i := 0; i < 6; i++ { + playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i)) + } + playlist.WriteString("#EXT-X-ENDLIST\n") + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(playlist.String())) + }) + for i := 0; i < 6; i++ { + mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", i), func(w http.ResponseWriter, r *http.Request) { + time.Sleep(150 * time.Millisecond) + w.Write([]byte("X")) + }) + } + server := httptest.NewServer(mux) + defer server.Close() + + f := newTestFetcher(t, testConfig()) + if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil { + t.Fatal(err) + } + if err := f.Start(); err != nil { + t.Fatal(err) + } + if err := f.Patch(&base.Request{URL: server.URL + "/other.m3u8"}, nil); err == nil { + t.Fatal("patching a running task must be rejected") + } + if err := f.Pause(); err != nil { + t.Fatal(err) + } + // After the pause the patch goes through and only then drops the plan. + if err := f.Patch(&base.Request{URL: server.URL + "/other.m3u8"}, nil); err != nil { + t.Fatal(err) + } + if f.state != nil { + t.Fatal("URL patch after pause must drop the plan") + } +} + +func TestFetcherE2E_RetryAfterFailure(t *testing.T) { + const total = 4 + var healthy atomic.Bool + mux := http.NewServeMux() + var playlist strings.Builder + playlist.WriteString("#EXTM3U\n") + for i := 0; i < total; i++ { + playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i)) + } + playlist.WriteString("#EXT-X-ENDLIST\n") + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(playlist.String())) + }) + segments := make(map[int][]byte) + for i := 0; i < total; i++ { + segments[i] = []byte(fmt.Sprintf("RETRY-SEGMENT-%02d", i)) + idx := i + mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", idx), func(w http.ResponseWriter, r *http.Request) { + if idx == 1 && !healthy.Load() { + w.WriteHeader(http.StatusInternalServerError) + return + } + time.Sleep(60 * time.Millisecond) + w.Write(segments[idx]) + }) + } + server := httptest.NewServer(mux) + defer server.Close() + + cfg := testConfig() + cfg.MaxRetries = 0 // fail the run on the first bad segment + cfg.PrefetchContentLength = false + f := newTestFetcher(t, cfg) + if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil { + t.Fatal(err) + } + if err := f.Start(); err != nil { + t.Fatal(err) + } + if err := f.Wait(); err == nil { + t.Fatal("first run should fail while the segment endpoint is down") + } + if got := statsOf(t, f).SegmentsFailed; got == 0 { + t.Fatal("failed segments should be reported in stats") + } + + // Server recovered: a fresh run must succeed and report zero failures. + healthy.Store(true) + if err := f.Start(); err != nil { + t.Fatal(err) + } + if err := f.Wait(); err != nil { + t.Fatalf("retry run failed: %v", err) + } + stats := statsOf(t, f) + if stats.SegmentsFailed != 0 { + t.Errorf("failures must reset per run, got %d", stats.SegmentsFailed) + } + if stats.SegmentsDone != total { + t.Errorf("want %d done, got %d", total, stats.SegmentsDone) + } + var want []byte + for i := 0; i < total; i++ { + want = append(want, segments[i]...) + } + if !bytes.Equal(readOutput(t, f), want) { + t.Errorf("retry output mismatch: %q", readOutput(t, f)) + } +} + +func TestFetcherE2E_StateMissingSelfHeal(t *testing.T) { + const total = 3 + mux := http.NewServeMux() + var playlist strings.Builder + playlist.WriteString("#EXTM3U\n") + for i := 0; i < total; i++ { + playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i)) + } + playlist.WriteString("#EXT-X-ENDLIST\n") + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(playlist.String())) + }) + segments := make(map[int][]byte) + for i := 0; i < total; i++ { + segments[i] = []byte(fmt.Sprintf("SELFHEAL-%02d", i)) + idx := i + mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", idx), func(w http.ResponseWriter, r *http.Request) { + w.Write(segments[idx]) + }) + } + server := httptest.NewServer(mux) + defer server.Close() + + f := newTestFetcher(t, testConfig()) + if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil { + t.Fatal(err) + } + // Simulate the crash window where the engine persisted the task with its + // resource but the HLS plan was never checkpointed. + f.state = nil + outputName := f.meta.Res.Files[0].Name + if err := f.Start(); err != nil { + t.Fatalf("start should self-heal a missing plan: %v", err) + } + if err := f.Wait(); err != nil { + t.Fatalf("self-healed run failed: %v", err) + } + if f.state == nil || len(f.state.Segments) != total { + t.Fatal("self-heal should rebuild the segment plan") + } + if f.meta.Res.Files[0].Name != outputName { + t.Fatalf("self-heal renamed the output: %s", f.meta.Res.Files[0].Name) + } + var want []byte + for i := 0; i < total; i++ { + want = append(want, segments[i]...) + } + if !bytes.Equal(readOutput(t, f), want) { + t.Errorf("self-healed output mismatch: %q", readOutput(t, f)) + } +} diff --git a/internal/protocol/hls/helper.go b/internal/protocol/hls/helper.go new file mode 100644 index 00000000..ba4a4f32 --- /dev/null +++ b/internal/protocol/hls/helper.go @@ -0,0 +1,100 @@ +package hls + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "net/http/cookiejar" + "time" + + "github.com/GopeedLab/gopeed/internal/httpclient" + "github.com/GopeedLab/gopeed/pkg/base" +) + +const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" + +const maxPlaylistSize = 10 << 20 // 10MB, playlists are small text files + +func (f *Fetcher) timeout() time.Duration { + return time.Duration(f.config.TimeoutSeconds) * time.Second +} + +// buildClient builds an HTTP client honoring the task proxy and TLS settings. +// The client has no total timeout on purpose: large segments over slow links +// must not be aborted; body stalls are handled by stallReader instead. It is +// only called while the fetcher is quiescent (Resolve/Start), never from +// inside running worker goroutines. +func (f *Fetcher) buildClient() *http.Client { + var proxy *base.RequestProxy + skipVerify := false + if req := f.meta.Req; req != nil { + proxy = req.Proxy + skipVerify = req.SkipVerifyCert + } + jar, _ := cookiejar.New(nil) + client, err := httpclient.NewClient(httpclient.Options{ + Client: httpclient.ClientOptions{ + Jar: jar, + }, + Transport: httpclient.TransportOptions{ + DialContext: (&net.Dialer{ + Timeout: f.timeout(), + }).DialContext, + Proxy: f.Ctl.GetProxy(proxy), + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: skipVerify, + }, + TLSHandshakeTimeout: f.timeout(), + }, + Impersonation: httpclient.ImpersonationOptions{ + Mode: httpclient.ImpersonationAuto, + Session: f.impSession, + }, + }) + if err != nil { + panic(fmt.Sprintf("build HTTP client: %v", err)) + } + return client +} + +// buildRequest builds a request with task extra headers, the default user +// agent and an optional byte range. extra may be nil. +func buildRequest(ctx context.Context, extra *ReqExtra, method, u string, body io.Reader, byterange *ByteRange) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, u, body) + if err != nil { + return nil, err + } + if extra != nil { + for key, value := range extra.Header { + req.Header.Set(key, value) + } + } + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", defaultUserAgent) + } + if byterange != nil { + req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", byterange.Offset, byterange.Offset+byterange.Length-1)) + } + return req, nil +} + +// stallReader aborts reads that see no data for the configured timeout. +type stallReader struct { + rc io.ReadCloser + timeout time.Duration +} + +func (r *stallReader) Read(p []byte) (int, error) { + timer := time.AfterFunc(r.timeout, func() { + r.rc.Close() + }) + defer timer.Stop() + return r.rc.Read(p) +} + +func (r *stallReader) Close() error { + return r.rc.Close() +} diff --git a/internal/protocol/hls/journal.go b/internal/protocol/hls/journal.go new file mode 100644 index 00000000..9d06a80b --- /dev/null +++ b/internal/protocol/hls/journal.go @@ -0,0 +1,112 @@ +package hls + +import ( + "crypto/rand" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "strconv" +) + +// journalFile is the persisted completed-segment ledger. It is bound to the +// exact download plan: a journal whose fingerprint does not match the current +// plan is discarded instead of being applied to the wrong segments. +type journalFile struct { + StagingID string `json:"stagingID"` + PlanHash string `json:"planHash"` + SegmentCount int `json:"segmentCount"` + Sizes map[string]int64 `json:"sizes"` +} + +// randomStagingID returns a random identifier used to give every task its own +// staging folder, stable across app restarts through the persisted state. +func randomStagingID() string { + buf := make([]byte, 6) + if _, err := rand.Read(buf); err != nil { + // crypto/rand failing is catastrophic anyway; fall back to address + // entropy rather than crashing the fetch. + return fmt.Sprintf("%x", sha1.Sum([]byte(fmt.Sprintf("%p", buf))))[:12] + } + return hex.EncodeToString(buf) +} + +// planFingerprint hashes everything that defines the download plan: the media +// URL, the ordered segments with their sequence numbers, byte ranges, keys +// and init markers. Two tasks resolve to different fingerprints even when +// they point at the same playlist URL. +func planFingerprint(state *fetcherState) string { + h := sha1.New() + io.WriteString(h, state.MediaURL+"\n") + io.WriteString(h, strconv.Itoa(len(state.Segments))+"\n") + for _, seg := range state.Segments { + fmt.Fprintf(h, "%d|%s|", seg.Sequence, seg.URI) + if seg.Byterange != nil { + fmt.Fprintf(h, "%d@%d|", seg.Byterange.Offset, seg.Byterange.Length) + } + if seg.Key != nil { + // The IV is part of the plan: same URI with a rotated IV would + // silently decrypt to garbage if the journal were reused. + fmt.Fprintf(h, "key=%s iv=%x|", seg.Key.URI, seg.Key.IV) + } + if seg.IsInit { + io.WriteString(h, "init|") + } + io.WriteString(h, "\n") + } + return hex.EncodeToString(h.Sum(nil))[:16] +} + +// loadJournal reads the journal of a run, discarding anything that cannot be +// proven to belong to the current plan: foreign or legacy journals, corrupted +// files, and entries whose recorded size no longer matches the plan. +func loadJournal(run *hlsRun) error { + data, err := os.ReadFile(run.journalPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var file journalFile + if err := json.Unmarshal(data, &file); err != nil { + // A broken or legacy journal must not kill a restartable download. + return nil + } + if file.StagingID != run.stagingID || file.PlanHash != run.planHash || file.SegmentCount != len(run.segs) { + return nil + } + for key, size := range file.Sizes { + idx, err := strconv.ParseInt(key, 10, 64) + if err != nil || idx < 0 || idx >= int64(len(run.segs)) || size <= 0 { + continue + } + run.journal[idx] = size + } + return nil +} + +// saveJournal atomically rewrites the journal through a temp file + rename so +// a crash never leaves a half-written ledger behind. +func saveJournal(run *hlsRun) error { + file := journalFile{ + StagingID: run.stagingID, + PlanHash: run.planHash, + SegmentCount: len(run.segs), + Sizes: make(map[string]int64, len(run.journal)), + } + for idx, size := range run.journal { + file.Sizes[strconv.FormatInt(idx, 10)] = size + } + data, err := json.Marshal(file) + if err != nil { + return err + } + tmp := run.journalPath + ".tmp" + if err := os.WriteFile(tmp, data, 0666); err != nil { + return err + } + return os.Rename(tmp, run.journalPath) +} diff --git a/internal/protocol/hls/media_validate_test.go b/internal/protocol/hls/media_validate_test.go new file mode 100644 index 00000000..276d2fd0 --- /dev/null +++ b/internal/protocol/hls/media_validate_test.go @@ -0,0 +1,297 @@ +package hls + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/GopeedLab/gopeed/pkg/base" +) + +// The validators below check real media structure, not just byte +// concatenation: TS packets are verified on the 188 byte grid with +// continuity-counter rules, PAT/PMT presence and PES start codes; fMP4 +// output is verified by walking the ISO-BMFF box hierarchy. + +func be32(b []byte) int { + return int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3]) +} + +// validateTS asserts structural validity of a merged MPEG-2 TS stream. +func validateTS(t *testing.T, data []byte) { + t.Helper() + if len(data) == 0 || len(data)%188 != 0 { + t.Fatalf("TS size %d is not a positive multiple of 188", len(data)) + } + type pidState struct{ cc int } + ccByPID := map[int]int{} + patSeen, pmtSeen, pesSeen := false, false, false + for off := 0; off < len(data); off += 188 { + pkt := data[off : off+188] + if pkt[0] != 0x47 { + t.Fatalf("bad TS sync byte at offset %d", off) + } + pid := (int(pkt[1]&0x1F) << 8) | int(pkt[2]) + pusi := pkt[1]&0x40 != 0 + afc := (pkt[3] >> 4) & 0x3 + if afc == 0 { + continue // reserved + } + idx := 4 + if afc&0x2 != 0 { // adaptation field present + afLen := int(pkt[4]) + if afLen > 0 && pkt[5]&0x80 != 0 { + // discontinuity_indicator: restart the CC expectation + delete(ccByPID, pid) + } + idx += 1 + afLen + if afc == 2 { + continue // adaptation only, no payload: no CC rule + } + } + if idx > 188 { + t.Fatalf("adaptation field overruns packet at offset %d", off) + } + if pid == 0x1FFF { + continue // null packet + } + payload := pkt[idx:] + if pusi && len(payload) >= 3 && pid != 0 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 { + pesSeen = true + } + if pid == 0 { + if ptr := int(payload[0]); 1+ptr < len(payload) && payload[1+ptr] == 0x00 { + patSeen = true // program_association_section table_id + } + } else if pusi && len(payload) >= 2 && payload[0] == 0 && payload[1] == 0x02 { + pmtSeen = true // program_map_section table_id + } + cc := int(pkt[3] & 0x0F) + prev, ok := ccByPID[pid] + if !ok { + ccByPID[pid] = cc + continue + } + // CC increments by one per payload-bearing packet; a repeated value + // models the single legal duplicate packet. + if cc != (prev+1)%16 && cc != prev { + t.Fatalf("continuity error on PID %d: %d after %d at offset %d", pid, cc, prev, off) + } + ccByPID[pid] = cc + } + if !patSeen { + t.Error("no PAT found in merged TS") + } + if !pmtSeen { + t.Error("no PMT found in merged TS") + } + if !pesSeen { + t.Error("no PES start code found in merged TS") + } +} + +// walkBoxes iterates the top-level ISO-BMFF boxes of data[off:end]. +func walkBoxes(t *testing.T, data []byte, off, end int) []string { + t.Helper() + var types []string + for off+8 <= end { + size := be32(data[off:]) + typ := string(data[off+4 : off+8]) + if size < 8 || off+size > end { + t.Fatalf("invalid box %q at offset %d (size %d)", typ, off, size) + } + types = append(types, typ) + off += size + } + return types +} + +// validateFMP4 asserts the merged fMP4 output is init section followed by +// well-formed moof+mdat fragments. +func validateFMP4(t *testing.T, merged []byte, wantFragments int) { + t.Helper() + types := walkBoxes(t, merged, 0, len(merged)) + hasFtyp, hasMoov := false, false + fragments := 0 + expectFragment := false + for i, typ := range types { + switch typ { + case "ftyp": + if i != 0 { + t.Errorf("ftyp not at stream start") + } + hasFtyp = true + case "moov": + if i != 1 { + t.Errorf("moov must follow ftyp, found at index %d", i) + } + hasMoov = true + case "styp": + expectFragment = true + case "moof": + if !hasMoov { + t.Errorf("fragment before moov") + } + expectFragment = true + case "mdat": + if !expectFragment { + t.Errorf("mdat outside of a fragment at index %d", i) + } + expectFragment = false + fragments++ + case "mfra": + // optional trailer + default: + t.Errorf("unexpected top-level box %q", typ) + } + } + if !hasFtyp || !hasMoov { + t.Error("merged fMP4 missing ftyp/moov init section") + } + if fragments != wantFragments { + t.Errorf("want %d fragments in merged fMP4, got %d", wantFragments, fragments) + } +} + +func mediaFixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile("testdata/media/" + name) + if err != nil { + t.Fatal(err) + } + return data +} + +func serveFixtureServer(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + fs := http.FileServer(http.Dir("testdata/media")) + mux.Handle("/", fs) + return httptest.NewServer(mux) +} + +func TestHLSRealMedia_TS(t *testing.T) { + seg0 := mediaFixture(t, "sample-00.ts") + seg1 := mediaFixture(t, "sample-01.ts") + playlist := "#EXTM3U\n" + + "#EXTINF:0.5,\nsample-00.ts\n" + + "#EXTINF:0.5,\nsample-01.ts\n" + + "#EXT-X-ENDLIST\n" + mux := http.NewServeMux() + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(playlist)) + }) + mux.Handle("/", http.FileServer(http.Dir("testdata/media"))) + server := httptest.NewServer(mux) + defer server.Close() + + f := newTestFetcher(t, testConfig()) + if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil { + t.Fatal(err) + } + if err := f.Start(); err != nil { + t.Fatal(err) + } + if err := f.Wait(); err != nil { + t.Fatal(err) + } + got := readOutput(t, f) + if !bytes.Equal(got, append(append([]byte{}, seg0...), seg1...)) { + t.Fatal("merged TS is not the exact concatenation of the segments") + } + validateTS(t, got) +} + +func TestHLSRealMedia_FMP4(t *testing.T) { + init := mediaFixture(t, "fmp4-init.mp4") + frag1 := mediaFixture(t, "fmp4-seg1.m4s") + frag2 := mediaFixture(t, "fmp4-seg2.m4s") + frag3 := mediaFixture(t, "fmp4-seg3.m4s") + playlist := "#EXTM3U\n" + + "#EXT-X-MAP:URI=\"fmp4-init.mp4\"\n" + + "#EXTINF:0.5,\nfmp4-seg1.m4s\n" + + "#EXTINF:0.5,\nfmp4-seg2.m4s\n" + + "#EXTINF:0.5,\nfmp4-seg3.m4s\n" + + "#EXT-X-ENDLIST\n" + mux := http.NewServeMux() + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(playlist)) + }) + mux.Handle("/", http.FileServer(http.Dir("testdata/media"))) + server := httptest.NewServer(mux) + defer server.Close() + + f := newTestFetcher(t, testConfig()) + if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil { + t.Fatal(err) + } + if err := f.Start(); err != nil { + t.Fatal(err) + } + if err := f.Wait(); err != nil { + t.Fatal(err) + } + got := readOutput(t, f) + want := append(append(append(append([]byte{}, init...), frag1...), frag2...), frag3...) + if !bytes.Equal(got, want) { + t.Fatal("merged fMP4 is not the exact concatenation of init + fragments") + } + validateFMP4(t, got, 3) +} + +func TestHLSRealMedia_AES128(t *testing.T) { + keys := [][]byte{[]byte("key-A-0123456789"), []byte("key-B-9876543210")} + iv := []byte("iv-1337-01234567") + plain := [][]byte{mediaFixture(t, "sample-00.ts"), mediaFixture(t, "sample-01.ts")} + + playlist := "#EXTM3U\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXT-X-KEY:METHOD=AES-128,URI=\"key-0.bin\",IV=0x" + hexEncode(iv) + "\n" + + "#EXTINF:0.5,\nenc-0.ts\n" + + "#EXT-X-KEY:METHOD=AES-128,URI=\"key-1.bin\"\n" + + "#EXTINF:0.5,\nenc-1.ts\n" + + "#EXT-X-ENDLIST\n" + mux := http.NewServeMux() + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(playlist)) + }) + for i, key := range keys { + k := key + mux.HandleFunc(fmt.Sprintf("/key-%d.bin", i), func(w http.ResponseWriter, r *http.Request) { + w.Write(k) + }) + // Segment 0 has an explicit IV, segment 1 derives it from its media + // sequence number (1). + enc := encryptSegment(plain[i], k, map[bool][]byte{true: iv, false: sequenceIV(int64(i))}[i == 0]) + mux.HandleFunc(fmt.Sprintf("/enc-%d.ts", i), func(w http.ResponseWriter, r *http.Request) { + w.Write(enc) + }) + } + server := httptest.NewServer(mux) + defer server.Close() + + f := newTestFetcher(t, testConfig()) + if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil { + t.Fatal(err) + } + if f.meta.Res.Size != 0 { + t.Errorf("encrypted plan must report indeterminate size, got %d", f.meta.Res.Size) + } + if err := f.Start(); err != nil { + t.Fatal(err) + } + if err := f.Wait(); err != nil { + t.Fatal(err) + } + got := readOutput(t, f) + if !bytes.Equal(got, append(append([]byte{}, plain[0]...), plain[1]...)) { + t.Fatal("decrypted output mismatch") + } + validateTS(t, got) + if p := f.Progress(); p.TotalDownloaded() != int64(len(got)) { + t.Errorf("final progress %d != merged size %d", p.TotalDownloaded(), len(got)) + } +} diff --git a/internal/protocol/hls/playlist.go b/internal/protocol/hls/playlist.go new file mode 100644 index 00000000..6dd1eeb2 --- /dev/null +++ b/internal/protocol/hls/playlist.go @@ -0,0 +1,479 @@ +package hls + +import ( + "encoding/hex" + "errors" + "fmt" + "net/url" + "path" + "strconv" + "strings" +) + +// Variant is one rendition entry of a master playlist. +type Variant struct { + URI string + Bandwidth int64 + AvgBandwidth int64 + Resolution string + Codecs string + Name string + AudioGroup string // AUDIO attribute: group id of the audio renditions +} + +// MediaRendition is one EXT-X-MEDIA entry of a master playlist. Only the +// fields needed to detect unsupported track layouts are kept. +type MediaRendition struct { + Type string + GroupID string + URI string +} + +// MasterPlaylist is a parsed master playlist with its rendition groups. +type MasterPlaylist struct { + Variants []*Variant + Rendition map[string][]*MediaRendition // group id -> all EXT-X-MEDIA members +} + +// Key is the decryption key applied to the segments that follow its tag, until +// the next EXT-X-KEY tag or the end of the playlist. +type Key struct { + Method string + URI string // absolute URI + IV []byte // explicit IV; nil means derive from the media sequence number +} + +// ByteRange is an EXT-X-BYTERANGE sub range of the segment resource. +type ByteRange struct { + Length int64 + Offset int64 +} + +// Segment is one downloadable piece of a media playlist. The init section of +// an fMP4 stream is represented as a segment with IsInit set. +type Segment struct { + URI string + Sequence int64 // media sequence number, used to derive the default AES IV + Byterange *ByteRange + Key *Key // nil means unencrypted + IsInit bool + Gap bool + Duration float64 + Size int64 // exact size in bytes when known (byterange / HEAD probe), 0 when unknown +} + +// Media is a parsed media playlist. +type Media struct { + Segments []*Segment + Version int + Live bool // no EXT-X-ENDLIST tag + IsFMP4 bool // playlist declares an init section via EXT-X-MAP + Discontinuity bool // playlist contains EXT-X-DISCONTINUITY +} + +const ( + keyMethodNone = "NONE" + keyMethodAES128 = "AES-128" + keyMethodSampleAES = "SAMPLE-AES" +) + +// IsMasterPlaylist reports whether the content looks like a master playlist. +func IsMasterPlaylist(content string) bool { + return strings.Contains(content, "#EXT-X-STREAM-INF") +} + +func validateM3U8Header(content string) error { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if line != "#EXTM3U" { + return errors.New("content is not a valid m3u8 playlist") + } + return nil + } + return errors.New("empty m3u8 playlist") +} + +// ParseMaster parses a master playlist and returns all variants. +func ParseMaster(content string, baseURL *url.URL) ([]*Variant, error) { + master, err := ParseMasterPlaylist(content, baseURL) + if err != nil { + return nil, err + } + return master.Variants, nil +} + +// ParseMasterPlaylist parses a master playlist including its EXT-X-MEDIA +// rendition groups. +func ParseMasterPlaylist(content string, baseURL *url.URL) (*MasterPlaylist, error) { + if err := validateM3U8Header(content); err != nil { + return nil, err + } + master := &MasterPlaylist{Rendition: make(map[string][]*MediaRendition)} + var pending *Variant + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "#EXT-X-STREAM-INF:") { + attrs := parseAttributes(line[len("#EXT-X-STREAM-INF:"):]) + pending = &Variant{ + Bandwidth: parseInt64(attrs["BANDWIDTH"]), + AvgBandwidth: parseInt64(attrs["AVERAGE-BANDWIDTH"]), + Resolution: attrs["RESOLUTION"], + Codecs: unquote(attrs["CODECS"]), + Name: unquote(attrs["NAME"]), + AudioGroup: unquote(attrs["AUDIO"]), + } + continue + } + if strings.HasPrefix(line, "#EXT-X-MEDIA:") { + attrs := parseAttributes(line[len("#EXT-X-MEDIA:"):]) + rendition := &MediaRendition{ + Type: strings.ToUpper(attrs["TYPE"]), + GroupID: unquote(attrs["GROUP-ID"]), + } + // Only an explicit URI means a separate track; resolving an empty + // reference would fabricate one out of the playlist URL. + if uri := unquote(attrs["URI"]); uri != "" { + rendition.URI = resolveURL(baseURL, uri) + } + if rendition.GroupID != "" { + // Keep every member: a group is unsupported as a whole when + // ANY of its members carries its own URI. + master.Rendition[rendition.GroupID] = append(master.Rendition[rendition.GroupID], rendition) + } + continue + } + if pending != nil { + if line[0] != '#' { + pending.URI = resolveURL(baseURL, line) + master.Variants = append(master.Variants, pending) + } + pending = nil + } + } + if len(master.Variants) == 0 { + return nil, errors.New("master playlist has no variants") + } + return master, nil +} + +// checkAudioRendition rejects the selected variant when its AUDIO group +// carries a separate audio track: downloading only the video variant would +// silently produce a silent file. Groups the variant does not reference are +// ignored (variants without an AUDIO attribute are assumed self-contained). +func checkAudioRendition(master *MasterPlaylist, best *Variant) error { + if best == nil || best.AudioGroup == "" { + return nil + } + for _, rendition := range master.Rendition[best.AudioGroup] { + if rendition == nil || rendition.Type != "AUDIO" || rendition.URI == "" { + continue + } + return fmt.Errorf( + "selected variant uses a separate audio rendition (group %q), which is not supported: the merged output would have no audio", + best.AudioGroup) + } + return nil +} + +// PickBestVariant selects the highest-bandwidth variant. +func PickBestVariant(variants []*Variant) *Variant { + var best *Variant + for _, v := range variants { + if best == nil || v.Bandwidth > best.Bandwidth { + best = v + } + } + return best +} + +// ParseMedia parses a media playlist into an ordered segment plan. +func ParseMedia(content string, baseURL *url.URL) (*Media, error) { + if err := validateM3U8Header(content); err != nil { + return nil, err + } + + media := &Media{Live: true} + var currentKey *Key + var pendingByterange *ByteRange + var pendingGap bool + var pendingDuration float64 + var lastRangeEnd int64 + var sequence int64 + var mapIdentity string // normalized identity of the current EXT-X-MAP + + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if line[0] != '#' { + // Every non-comment line in a media playlist is a segment URI. + seg := &Segment{ + URI: resolveURL(baseURL, line), + Sequence: sequence, + Byterange: pendingByterange, + Key: currentKey, + Gap: pendingGap, + Duration: pendingDuration, + } + if pendingByterange != nil { + // Byterange segments have exact sizes straight from the playlist. + seg.Size = pendingByterange.Length + } + sequence++ + pendingByterange = nil + pendingGap = false + pendingDuration = 0 + media.Segments = append(media.Segments, seg) + continue + } + + switch { + case strings.HasPrefix(line, "#EXTINF:"): + value := line[len("#EXTINF:"):] + if idx := strings.Index(value, ","); idx >= 0 { + value = value[:idx] + } + pendingDuration = parseFloat(value) + case strings.HasPrefix(line, "#EXT-X-KEY:"): + key, err := parseKey(line[len("#EXT-X-KEY:"):], baseURL) + if err != nil { + return nil, err + } + currentKey = key + case strings.HasPrefix(line, "#EXT-X-MAP:"): + attrs := parseAttributes(line[len("#EXT-X-MAP:"):]) + uriAttr := unquote(attrs["URI"]) + if uriAttr == "" { + return nil, errors.New("EXT-X-MAP without URI is not supported") + } + resolved := resolveURL(baseURL, uriAttr) + // The init section identity covers every attribute, not just the + // URI: a repeated tag with a different BYTERANGE addresses + // different bytes and changes the init section. NUL keeps the + // two parts unambiguously separated. + identity := resolved + "\x00" + attrs["BYTERANGE"] + if mapIdentity != "" { + if mapIdentity != identity { + return nil, errors.New("changing init sections are not supported") + } + media.IsFMP4 = true + continue + } + mapIdentity = identity + init := &Segment{ + URI: resolved, + Sequence: sequence, + IsInit: true, + Key: currentKey, + } + if attrs["BYTERANGE"] != "" { + init.Byterange = parseByteRange(attrs["BYTERANGE"], 0) + if init.Byterange == nil { + return nil, fmt.Errorf("invalid EXT-X-MAP BYTERANGE: %s", attrs["BYTERANGE"]) + } + } + if init.Key != nil && init.Key.IV == nil { + // RFC 8216 only defines sequence-derived IVs for media + // segments; guessing one for the init section produces + // corrupt output, so require the playlist to be explicit. + return nil, errors.New("encrypted EXT-X-MAP without an explicit IV is not supported") + } + // The init section is prepended to the segment plan, it takes + // the media sequence number of the playlist for IV derivation. + media.Segments = append(media.Segments, init) + media.IsFMP4 = true + case strings.HasPrefix(line, "#EXT-X-BYTERANGE:"): + pendingByterange = parseByteRange(line[len("#EXT-X-BYTERANGE:"):], lastRangeEnd) + if pendingByterange == nil { + return nil, fmt.Errorf("invalid EXT-X-BYTERANGE: %s", line) + } + case line == "#EXT-X-GAP": + pendingGap = true + case line == "#EXT-X-DISCONTINUITY": + media.Discontinuity = true + case line == "#EXT-X-ENDLIST": + media.Live = false + case strings.HasPrefix(line, "#EXT-X-MEDIA-SEQUENCE:"): + sequence = parseInt64(line[len("#EXT-X-MEDIA-SEQUENCE:"):]) + case strings.HasPrefix(line, "#EXT-X-VERSION:"): + media.Version = int(parseInt64(line[len("#EXT-X-VERSION:"):])) + } + + if pendingByterange != nil { + // Non-segment tags between BYTERANGE and its URI must not reset the + // running offset, so it is tracked here after each parse. + lastRangeEnd = pendingByterange.Offset + pendingByterange.Length + } + } + + if len(media.Segments) == 0 { + return nil, errors.New("media playlist has no segments") + } + return media, nil +} + +// validateSupported rejects playlist features the byte-concatenation merge +// cannot reproduce faithfully. Skipping such features silently would report +// success while producing a broken file. +func validateSupported(media *Media) error { + if media.Live { + return errors.New("live streams are not supported yet") + } + if media.Discontinuity { + return errors.New("EXT-X-DISCONTINUITY is not supported: the merged output would have a broken timeline") + } + for _, seg := range media.Segments { + if seg.Gap { + return errors.New("EXT-X-GAP is not supported: the missing segment would leave a hole in the merged output") + } + } + return nil +} + +func parseKey(attrs string, baseURL *url.URL) (*Key, error) { + parsed := parseAttributes(attrs) + switch method := parsed["METHOD"]; method { + case keyMethodNone: + return nil, nil + case keyMethodAES128: + uri := unquote(parsed["URI"]) + if uri == "" { + return nil, errors.New("AES-128 EXT-X-KEY without URI") + } + if format := unquote(parsed["KEYFORMAT"]); format != "" && format != "identity" { + return nil, fmt.Errorf("unsupported EXT-X-KEY KEYFORMAT: %s", format) + } + key := &Key{Method: keyMethodAES128, URI: resolveURL(baseURL, uri)} + if iv, ok := parsed["IV"]; ok { + value := strings.TrimPrefix(strings.ToLower(iv), "0x") + decoded, err := hex.DecodeString(value) + if err != nil || len(decoded) != 16 { + return nil, fmt.Errorf("invalid EXT-X-KEY IV: %s", iv) + } + key.IV = decoded + } + return key, nil + case keyMethodSampleAES: + return nil, errors.New("SAMPLE-AES encryption is not supported") + default: + return nil, fmt.Errorf("unsupported encryption method: %s", method) + } +} + +func parseByteRange(value string, defaultOffset int64) *ByteRange { + lengthPart := value + offsetPart := "" + if idx := strings.Index(value, "@"); idx >= 0 { + lengthPart = value[:idx] + offsetPart = value[idx+1:] + } + length := parseInt64(lengthPart) + if length <= 0 { + return nil + } + offset := defaultOffset + if offsetPart != "" { + offset = parseInt64(offsetPart) + if offset < 0 { + return nil + } + } + return &ByteRange{Length: length, Offset: offset} +} + +// resolveURL resolves a playlist URI reference against the playlist URL +// following RFC 3986. +func resolveURL(baseURL *url.URL, ref string) string { + refURL, err := url.Parse(ref) + if err != nil { + return ref + } + return baseURL.ResolveReference(refURL).String() +} + +// parseAttributes parses an HLS attribute list like FOO="bar",NUM=42. +func parseAttributes(s string) map[string]string { + attrs := make(map[string]string) + var key, val strings.Builder + inKey := true + inQuote := false + flush := func() { + name := strings.ToUpper(strings.TrimSpace(key.String())) + if name != "" { + attrs[name] = strings.TrimSpace(val.String()) + } + key.Reset() + val.Reset() + inKey = true + } + for i := 0; i < len(s); i++ { + c := s[i] + if inKey { + if c == '=' { + inKey = false + } else { + key.WriteByte(c) + } + continue + } + if c == '"' { + inQuote = !inQuote + continue + } + if c == ',' && !inQuote { + flush() + continue + } + val.WriteByte(c) + } + flush() + return attrs +} + +func unquote(s string) string { + s = strings.TrimSpace(s) + if len(s) >= 2 && strings.HasPrefix(s, "\"") && strings.HasSuffix(s, "\"") { + return s[1 : len(s)-1] + } + return s +} + +func parseInt64(s string) int64 { + value, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64) + return value +} + +func parseFloat(s string) float64 { + value, _ := strconv.ParseFloat(strings.TrimSpace(s), 64) + return value +} + +// OutputExtension returns the container file extension for the playlist. +func (m *Media) OutputExtension() string { + if m.IsFMP4 { + return ".mp4" + } + return ".ts" +} + +// DeriveOutputName builds the merged output file name from a playlist URL. +func DeriveOutputName(playlistURL string, media *Media) string { + name := "" + if u, err := url.Parse(playlistURL); err == nil { + name = path.Base(u.Path) + name = strings.TrimSuffix(name, path.Ext(name)) + } + if name == "" || name == "/" || name == "." { + if u, err := url.Parse(playlistURL); err == nil { + name = u.Hostname() + } + } + return name + media.OutputExtension() +} diff --git a/internal/protocol/hls/playlist_realworld_test.go b/internal/protocol/hls/playlist_realworld_test.go new file mode 100644 index 00000000..6a9de3c8 --- /dev/null +++ b/internal/protocol/hls/playlist_realworld_test.go @@ -0,0 +1,39 @@ +package hls + +import ( + _ "embed" + "net/url" + "testing" +) + +//go:embed testdata/olemovienews.m3u8 +var realWorldPlaylist string + +// TestParseMediaRealWorld guards parsing against a real site playlist +// (olemovienews.com VOD, 138 TS segments). +func TestParseMediaRealWorld(t *testing.T) { + base, err := url.Parse("https://europe.olemovienews.com/ts2/20230125/afAxfjit/mp4/afAxfjit.mp4/index-v1-a1.m3u8") + if err != nil { + t.Fatal(err) + } + media, err := ParseMedia(realWorldPlaylist, base) + if err != nil { + t.Fatal(err) + } + if media.Live { + t.Error("real VOD playlist should not be live") + } + if media.IsFMP4 { + t.Error("playlist should be detected as TS") + } + if len(media.Segments) != 138 { + t.Errorf("want 138 segments, got %d", len(media.Segments)) + } + first := media.Segments[0] + if first.URI != "https://europe.olemovienews.com/ts2/20230125/afAxfjit/mp4/afAxfjit.mp4/seg-1-v1-a1.ts" { + t.Errorf("segment URI resolution wrong: %s", first.URI) + } + if media.Segments[0].Sequence != 1 { + t.Errorf("first sequence = %d, want 1", media.Segments[0].Sequence) + } +} diff --git a/internal/protocol/hls/playlist_test.go b/internal/protocol/hls/playlist_test.go new file mode 100644 index 00000000..fb219aa4 --- /dev/null +++ b/internal/protocol/hls/playlist_test.go @@ -0,0 +1,252 @@ +package hls + +import ( + "net/url" + "testing" +) + +func mustURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + return u +} + +func TestParseMediaTS(t *testing.T) { + content := "#EXTM3U\n" + + "#EXT-X-TARGETDURATION:20\n" + + "#EXT-X-VERSION:3\n" + + "#EXT-X-MEDIA-SEQUENCE:1\n" + + "#EXTINF:20.000,\n" + + "seg-1.ts\n" + + "#EXTINF:20.000,\n" + + "seg-2.ts\n" + + "#EXT-X-ENDLIST\n" + media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8")) + if err != nil { + t.Fatal(err) + } + if media.Live { + t.Error("VOD playlist should not be live") + } + if media.IsFMP4 { + t.Error("TS playlist should not be fMP4") + } + if len(media.Segments) != 2 { + t.Fatalf("want 2 segments, got %d", len(media.Segments)) + } + want := "https://cdn.example.com/vod/seg-1.ts" + if media.Segments[0].URI != want { + t.Errorf("want uri %s, got %s", want, media.Segments[0].URI) + } + if media.Segments[0].Sequence != 1 { + t.Errorf("want sequence 1, got %d", media.Segments[0].Sequence) + } + if media.Segments[0].Key != nil { + t.Error("segment should be unencrypted") + } +} + +func TestParseMediaWithoutExtinf(t *testing.T) { + // Some playlists list segment URIs without EXTINF lines. + content := "#EXTM3U\nseg-1.ts\nseg-2.ts\n#EXT-X-ENDLIST\n" + media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/a/index.m3u8")) + if err != nil { + t.Fatal(err) + } + if len(media.Segments) != 2 { + t.Fatalf("want 2 segments, got %d", len(media.Segments)) + } +} + +func TestParseMediaLive(t *testing.T) { + content := "#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXTINF:6.0,\nseg-1.ts\n" + media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/live/index.m3u8")) + if err != nil { + t.Fatal(err) + } + if !media.Live { + t.Error("playlist without ENDLIST should be live") + } +} + +func TestParseMediaFMP4(t *testing.T) { + content := "#EXTM3U\n" + + "#EXT-X-VERSION:6\n" + + "#EXT-X-MAP:URI=\"init.mp4\"\n" + + "#EXTINF:5.0,\n" + + "seg-1.m4s\n" + + "#EXT-X-ENDLIST\n" + media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8")) + if err != nil { + t.Fatal(err) + } + if !media.IsFMP4 { + t.Error("playlist with MAP should be fMP4") + } + if len(media.Segments) != 2 { + t.Fatalf("want 2 segments (init + 1), got %d", len(media.Segments)) + } + if !media.Segments[0].IsInit { + t.Error("first segment should be the init section") + } + if media.Segments[0].URI != "https://cdn.example.com/vod/init.mp4" { + t.Errorf("init uri wrong: %s", media.Segments[0].URI) + } + if media.OutputExtension() != ".mp4" { + t.Errorf("want .mp4, got %s", media.OutputExtension()) + } +} + +func TestParseMediaMultiMapError(t *testing.T) { + content := "#EXTM3U\n" + + "#EXT-X-MAP:URI=\"init1.mp4\"\n" + + "#EXTINF:5.0,\nseg-1.m4s\n" + + "#EXT-X-DISCONTINUITY\n" + + "#EXT-X-MAP:URI=\"init2.mp4\"\n" + + "#EXTINF:5.0,\nseg-2.m4s\n" + + "#EXT-X-ENDLIST\n" + if _, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8")); err == nil { + t.Error("multi-period fMP4 should be rejected") + } +} + +func TestParseMediaKeyRotation(t *testing.T) { + content := "#EXTM3U\n" + + "#EXT-X-KEY:METHOD=AES-128,URI=\"key1.bin\",IV=0x00000000000000000000000000000001\n" + + "#EXTINF:5.0,\nseg-1.ts\n" + + "#EXT-X-KEY:METHOD=AES-128,URI=\"key2.bin\"\n" + + "#EXTINF:5.0,\nseg-2.ts\n" + + "#EXT-X-KEY:METHOD=NONE\n" + + "#EXTINF:5.0,\nseg-3.ts\n" + + "#EXT-X-ENDLIST\n" + media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8")) + if err != nil { + t.Fatal(err) + } + if media.Segments[0].Key == nil || media.Segments[0].Key.URI != "https://cdn.example.com/vod/key1.bin" { + t.Fatalf("segment 1 key wrong: %+v", media.Segments[0].Key) + } + if len(media.Segments[0].Key.IV) != 16 || media.Segments[0].Key.IV[15] != 1 { + t.Error("explicit IV not parsed") + } + if media.Segments[1].Key == nil || media.Segments[1].Key.URI != "https://cdn.example.com/vod/key2.bin" { + t.Fatalf("segment 2 key wrong: %+v", media.Segments[1].Key) + } + if media.Segments[1].Key.IV != nil { + t.Error("key without IV should leave IV nil (sequence-derived)") + } + if media.Segments[2].Key != nil { + t.Error("segment 3 should be unencrypted after METHOD=NONE") + } +} + +func TestParseMediaSampleAESError(t *testing.T) { + content := "#EXTM3U\n#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"skd://key\"\n#EXTINF:5.0,\nseg-1.ts\n" + if _, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8")); err == nil { + t.Error("SAMPLE-AES should be rejected") + } +} + +func TestParseMediaByterange(t *testing.T) { + content := "#EXTM3U\n" + + "#EXT-X-BYTERANGE:1000@0\nseg.ts\n" + + "#EXT-X-BYTERANGE:2000\nseg.ts\n" + + "#EXT-X-BYTERANGE:3000\nseg.ts\n" + + "#EXT-X-ENDLIST\n" + media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8")) + if err != nil { + t.Fatal(err) + } + ranges := [][2]int64{{0, 1000}, {1000, 2000}, {3000, 3000}} + for i, want := range ranges { + br := media.Segments[i].Byterange + if br == nil { + t.Fatalf("segment %d byterange missing", i) + } + if br.Offset != want[0] || br.Length != want[1] { + t.Errorf("segment %d: want offset %d length %d, got %d/%d", i, want[0], want[1], br.Offset, br.Length) + } + } +} + +func TestParseMediaGap(t *testing.T) { + content := "#EXTM3U\n#EXTINF:5.0,\nseg-1.ts\n#EXT-X-GAP\n#EXTINF:5.0,\nseg-2.ts\n#EXT-X-ENDLIST\n" + media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8")) + if err != nil { + t.Fatal(err) + } + if media.Segments[0].Gap || !media.Segments[1].Gap { + t.Error("gap flags wrong") + } +} + +func TestParseMediaInvalidHeader(t *testing.T) { + if _, err := ParseMedia("not a playlist", mustURL(t, "https://cdn.example.com/a.m3u8")); err == nil { + t.Error("invalid content should be rejected") + } +} + +func TestParseMasterPickBest(t *testing.T) { + content := "#EXTM3U\n" + + "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",DEFAULT=YES,URI=\"audio/index.m3u8\"\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360\n" + + "360p/index.m3u8\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1920x1080,CODECS=\"avc1.640028,mp4a.40.2\"\n" + + "1080p/index.m3u8?sign=abc\n" + + "#EXT-X-ENDLIST\n" + variants, err := ParseMaster(content, mustURL(t, "https://cdn.example.com/vod/master.m3u8")) + if err != nil { + t.Fatal(err) + } + if len(variants) != 2 { + t.Fatalf("want 2 variants, got %d", len(variants)) + } + best := PickBestVariant(variants) + if best.Bandwidth != 2500000 { + t.Errorf("want highest bandwidth variant, got %d", best.Bandwidth) + } + if best.URI != "https://cdn.example.com/vod/1080p/index.m3u8?sign=abc" { + t.Errorf("variant uri wrong: %s", best.URI) + } +} + +func TestResolveURLQueryNotInherited(t *testing.T) { + base := mustURL(t, "https://cdn.example.com/vod/index.m3u8?sign=abc") + got := resolveURL(base, "seg-1.ts") + if got != "https://cdn.example.com/vod/seg-1.ts" { + t.Errorf("relative resolution should follow RFC3986 (no query inheritance), got %s", got) + } +} + +func TestDeriveOutputName(t *testing.T) { + media := &Media{} + if got := DeriveOutputName("https://cdn.example.com/ts2/a/index-v1-a1.m3u8", media); got != "index-v1-a1.ts" { + t.Errorf("want index-v1-a1.ts, got %s", got) + } + fmp4 := &Media{IsFMP4: true} + if got := DeriveOutputName("https://cdn.example.com/ts2/a/index.m3u8", fmp4); got != "index.mp4" { + t.Errorf("want index.mp4, got %s", got) + } + if got := DeriveOutputName("https://cdn.example.com/play?token=x", media); got != "play.ts" { + t.Errorf("want play.ts, got %s", got) + } + if got := DeriveOutputName("https://cdn.example.com/", media); got != "cdn.example.com.ts" { + t.Errorf("want host fallback, got %s", got) + } +} + +func TestParseAttributesQuotedComma(t *testing.T) { + attrs := parseAttributes(`URI="a,b.ts",IV=0xABCDEF00000000000000000000000001,NAME="x"`) + if attrs["URI"] != "a,b.ts" { + t.Errorf("quoted comma value wrong: %q", attrs["URI"]) + } + if attrs["IV"] != "0xABCDEF00000000000000000000000001" { + t.Errorf("hex value wrong: %q", attrs["IV"]) + } + if attrs["NAME"] != "x" { + t.Errorf("quoted value wrong: %q", attrs["NAME"]) + } +} diff --git a/internal/protocol/hls/staging_test.go b/internal/protocol/hls/staging_test.go new file mode 100644 index 00000000..ec25acbf --- /dev/null +++ b/internal/protocol/hls/staging_test.go @@ -0,0 +1,296 @@ +package hls + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/GopeedLab/gopeed/pkg/base" +) + +func TestFetcherE2E_SameURLTwoTasks(t *testing.T) { + const total = 6 + mux := http.NewServeMux() + var playlist strings.Builder + playlist.WriteString("#EXTM3U\n") + for i := 0; i < total; i++ { + playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i)) + } + playlist.WriteString("#EXT-X-ENDLIST\n") + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(playlist.String())) + }) + segments := make(map[int][]byte) + for i := 0; i < total; i++ { + segments[i] = []byte(fmt.Sprintf("SHARED-SEGMENT-%02d", i)) + idx := i + mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", idx), func(w http.ResponseWriter, r *http.Request) { + time.Sleep(120 * time.Millisecond) + w.Write(segments[idx]) + }) + } + server := httptest.NewServer(mux) + defer server.Close() + + cfg := testConfig() + cfg.SegmentConnections = 2 + cfg.PrefetchContentLength = false + dir := t.TempDir() + + // Two tasks for the same playlist URL into the same directory, like a + // user duplicating a task. The engine would rename the second output + // (AutoRename); mirror that here. + f1 := newTestFetcher(t, cfg) + if err := f1.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: dir}); err != nil { + t.Fatal(err) + } + f2 := newTestFetcher(t, cfg) + if err := f2.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: dir, Name: "video-2.ts"}); err != nil { + t.Fatal(err) + } + if f1.state.StagingDir == "" || f1.state.StagingDir == f2.state.StagingDir { + t.Fatalf("staging dirs must be distinct per task: %q vs %q", f1.state.StagingDir, f2.state.StagingDir) + } + + // Start task 1, let it stage some segments, then pause it. + if err := f1.Start(); err != nil { + t.Fatal(err) + } + time.Sleep(250 * time.Millisecond) + if err := f1.Pause(); err != nil { + t.Fatal(err) + } + staged1, err := os.ReadDir(f1.state.StagingDir) + if err != nil || len(staged1) == 0 { + t.Fatalf("task 1 should have staged segments, dir: %s", f1.state.StagingDir) + } + + // Task 2 must complete without touching task 1's staging data... + if err := f2.Start(); err != nil { + t.Fatal(err) + } + if err := f2.Wait(); err != nil { + t.Fatal(err) + } + stillThere, err := os.ReadDir(f1.state.StagingDir) + if err != nil || len(stillThere) != len(staged1) { + t.Fatalf("task 2 disturbed task 1 staging: had %d entries, now %d", len(staged1), len(stillThere)) + } + + // ...and task 1 resumes to a complete, correct output of its own. + if err := f1.Start(); err != nil { + t.Fatal(err) + } + if err := f1.Wait(); err != nil { + t.Fatal(err) + } + var want []byte + for i := 0; i < total; i++ { + want = append(want, segments[i]...) + } + if !bytes.Equal(readOutput(t, f1), want) || !bytes.Equal(readOutput(t, f2), want) { + t.Error("same-URL task outputs mismatch") + } + entries, _ := os.ReadDir(dir) + if len(entries) != 2 { + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("expected only the two outputs to remain, got %v", names) + } +} + +func TestFetcherE2E_ForeignJournalDiscarded(t *testing.T) { + const total = 3 + var served atomic.Int64 + mux := http.NewServeMux() + var playlist strings.Builder + playlist.WriteString("#EXTM3U\n") + for i := 0; i < total; i++ { + playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i)) + } + playlist.WriteString("#EXT-X-ENDLIST\n") + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(playlist.String())) + }) + for i := 0; i < total; i++ { + mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", i), func(w http.ResponseWriter, r *http.Request) { + served.Add(1) + w.Write([]byte("DATA")) + }) + } + server := httptest.NewServer(mux) + defer server.Close() + + cfg := testConfig() + cfg.PrefetchContentLength = false + f := newTestFetcher(t, cfg) + if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil { + t.Fatal(err) + } + // Plant a journal that does not belong to this plan (wrong fingerprint). + tempDir := f.resolveStagingDirLocked() + if err := os.MkdirAll(tempDir, 0777); err != nil { + t.Fatal(err) + } + foreign := journalFile{ + StagingID: "some-other-task", + PlanHash: "deadbeef", + SegmentCount: total, + Sizes: map[string]int64{"0": 4, "1": 4, "2": 4}, + } + data, _ := json.Marshal(foreign) + if err := os.WriteFile(filepath.Join(tempDir, "journal.json"), data, 0666); err != nil { + t.Fatal(err) + } + + if err := f.Start(); err != nil { + t.Fatal(err) + } + if err := f.Wait(); err != nil { + t.Fatal(err) + } + if got := served.Load(); got != total { + t.Errorf("foreign journal must be discarded, want %d downloads, got %d", total, got) + } +} + +func TestFetcherE2E_MergeFailurePreservesStaging(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("#EXTM3U\n#EXTINF:4,\na.ts\n#EXTINF:4,\nb.ts\n#EXT-X-ENDLIST\n")) + }) + mux.HandleFunc("/a.ts", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("AAAA")) }) + mux.HandleFunc("/b.ts", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("BBBB")) }) + server := httptest.NewServer(mux) + defer server.Close() + + f := newTestFetcher(t, testConfig()) + if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil { + t.Fatal(err) + } + run := &hlsRun{ + ctx: context.Background(), + cancel: func() {}, + done: make(chan struct{}), + segs: f.state.Segments, + outputPath: f.meta.SingleFilepath(), + stagingID: f.state.StagingID, + planHash: planFingerprint(f.state), + tempDir: f.resolveStagingDirLocked(), + keyCache: make(map[string][]byte), + journal: make(map[int64]int64), + } + run.journalPath = filepath.Join(run.tempDir, "journal.json") + if err := os.MkdirAll(run.tempDir, 0777); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(segmentPath(run.tempDir, 0), []byte("AAAA"), 0666); err != nil { + t.Fatal(err) + } + // Segment 1 is missing on disk: the merge must fail without deleting the + // staged data or leaving a partial output behind. + if err := f.merge(run); err == nil { + t.Fatal("merge with a missing segment should fail") + } + if _, err := os.Stat(segmentPath(run.tempDir, 0)); err != nil { + t.Fatal("merge failure must preserve staged segments") + } + entries, _ := os.ReadDir(filepath.Dir(run.outputPath)) + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".part") { + t.Errorf("failed merge left a partial output behind: %s", e.Name()) + } + } + + // Recover the segment: the retried merge succeeds. + if err := os.WriteFile(segmentPath(run.tempDir, 1), []byte("BBBB"), 0666); err != nil { + t.Fatal(err) + } + if err := f.merge(run); err != nil { + t.Fatalf("retried merge failed: %v", err) + } + got, err := os.ReadFile(run.outputPath) + if err != nil { + t.Fatal(err) + } + if string(got) != "AAAABBBB" { + t.Errorf("retried merge output mismatch: %q", got) + } +} + +func TestFetcherE2E_ReMergeOverExistingOutput(t *testing.T) { + const total = 2 + mux := http.NewServeMux() + var playlist strings.Builder + playlist.WriteString("#EXTM3U\n") + for i := 0; i < total; i++ { + playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i)) + } + playlist.WriteString("#EXT-X-ENDLIST\n") + mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(playlist.String())) + }) + for i := 0; i < total; i++ { + mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", i), func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("DATA")) + }) + } + server := httptest.NewServer(mux) + defer server.Close() + + f := newTestFetcher(t, testConfig()) + if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil { + t.Fatal(err) + } + tempDir := f.resolveStagingDirLocked() + if err := os.MkdirAll(tempDir, 0777); err != nil { + t.Fatal(err) + } + // Simulate the crash window after a completed rename but before the task + // was marked done: a stale output file, a complete journal and all + // segments staged. + stale := filepath.Join(filepath.Dir(f.meta.SingleFilepath()), f.state.OutputName) + if err := os.WriteFile(stale, []byte("STALE-OUTPUT"), 0666); err != nil { + t.Fatal(err) + } + jr := &hlsRun{ + segs: f.state.Segments, + stagingID: f.state.StagingID, + planHash: planFingerprint(f.state), + journalPath: filepath.Join(tempDir, "journal.json"), + journal: map[int64]int64{0: 4, 1: 4}, + } + if err := saveJournal(jr); err != nil { + t.Fatal(err) + } + for i := 0; i < total; i++ { + if err := os.WriteFile(segmentPath(tempDir, int64(i)), []byte("DATA"), 0666); err != nil { + t.Fatal(err) + } + } + + if err := f.Start(); err != nil { + t.Fatal(err) + } + if err := f.Wait(); err != nil { + t.Fatalf("re-merge over an existing output failed: %v", err) + } + got, err := os.ReadFile(stale) + if err != nil { + t.Fatal(err) + } + if string(got) != "DATADATA" { + t.Errorf("stale output was not replaced: %q", got) + } +} diff --git a/internal/protocol/hls/subset_test.go b/internal/protocol/hls/subset_test.go new file mode 100644 index 00000000..18ef4328 --- /dev/null +++ b/internal/protocol/hls/subset_test.go @@ -0,0 +1,146 @@ +package hls + +import ( + "net/url" + "strings" + "testing" +) + +func mustParseURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + return u +} + +// subsetCase is a media playlist plus the error substring the pipeline must +// reject it with; wantOK playlists must parse AND validate cleanly. +type subsetCase struct { + name string + playlist string + wantErr string +} + +func TestSubsetRejections(t *testing.T) { + cases := []subsetCase{ + { + name: "gap", + playlist: "#EXTM3U\n#EXTINF:4,\na.ts\n#EXT-X-GAP\n#EXTINF:4,\nb.ts\n#EXT-X-ENDLIST\n", + wantErr: "EXT-X-GAP", + }, + { + name: "gap-before-first-segment", + playlist: "#EXTM3U\n#EXT-X-GAP\n#EXTINF:4,\na.ts\n#EXT-X-ENDLIST\n", + wantErr: "EXT-X-GAP", + }, + { + name: "discontinuity", + playlist: "#EXTM3U\n#EXTINF:4,\na.ts\n#EXT-X-DISCONTINUITY\n#EXTINF:4,\nb.ts\n#EXT-X-ENDLIST\n", + wantErr: "EXT-X-DISCONTINUITY", + }, + { + name: "live", + playlist: "#EXTM3U\n#EXTINF:4,\na.ts\n", + wantErr: "live", + }, + { + name: "keyformat", + playlist: "#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"k\",KEYFORMAT=\"com.apple.streamingkeydelivery\"\n#EXTINF:4,\na.ts\n#EXT-X-ENDLIST\n", + wantErr: "KEYFORMAT", + }, + { + name: "encrypted-map-without-iv", + playlist: "#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"k\"\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXTINF:4,\na.m4s\n#EXT-X-ENDLIST\n", + wantErr: "explicit IV", + }, + { + name: "map-change-byterange", + playlist: "#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"100@0\"\n#EXTINF:4,\na.m4s\n#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"100@100\"\n#EXTINF:4,\nb.m4s\n#EXT-X-ENDLIST\n", + wantErr: "changing init sections", + }, + { + name: "map-change-uri", + playlist: "#EXTM3U\n#EXT-X-MAP:URI=\"init1.mp4\"\n#EXTINF:4,\na.m4s\n#EXT-X-MAP:URI=\"init2.mp4\"\n#EXTINF:4,\nb.m4s\n#EXT-X-ENDLIST\n", + wantErr: "changing init sections", + }, + } + baseURL := mustParseURL(t, "https://cdn.example.com/vod/index.m3u8") + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + media, parseErr := ParseMedia(tc.playlist, baseURL) + if parseErr != nil { + if !strings.Contains(parseErr.Error(), tc.wantErr) { + t.Fatalf("parse error %q does not mention %q", parseErr, tc.wantErr) + } + return + } + err := validateSupported(media) + if err == nil { + t.Fatalf("playlist should be rejected with %q", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error %q does not mention %q", err, tc.wantErr) + } + }) + } +} + +func TestSubsetRepeatedSameMapAllowed(t *testing.T) { + playlist := "#EXTM3U\n" + + "#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"100@0\"\n" + + "#EXTINF:4,\na.m4s\n" + + "#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"100@0\"\n" + + "#EXTINF:4,\nb.m4s\n" + + "#EXT-X-ENDLIST\n" + media, err := ParseMedia(playlist, mustParseURL(t, "https://cdn.example.com/vod/index.m3u8")) + if err != nil { + t.Fatal(err) + } + if err := validateSupported(media); err != nil { + t.Fatalf("identical repeated MAP must be allowed: %v", err) + } + if len(media.Segments) != 3 { // init + 2 segments, no duplicate init + t.Fatalf("want 3 segments (init + 2), got %d", len(media.Segments)) + } +} + +func TestMasterAudioRenditionDetection(t *testing.T) { + masterURL := mustParseURL(t, "https://cdn.example.com/master.m3u8") + + separateAudio := "#EXTM3U\n" + + "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",DEFAULT=YES,URI=\"audio/index.m3u8\"\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=1000000,AUDIO=\"aud\"\nvideo/index.m3u8\n" + master, err := ParseMasterPlaylist(separateAudio, masterURL) + if err != nil { + t.Fatal(err) + } + if err := checkAudioRendition(master, PickBestVariant(master.Variants)); err == nil { + t.Fatal("selected variant with a separate audio rendition must be rejected") + } + + // A group the variant does not reference must not cause a false positive. + unrelatedGroup := "#EXTM3U\n" + + "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",DEFAULT=YES,URI=\"audio/index.m3u8\"\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=1000000\nvideo/index.m3u8\n" + master, err = ParseMasterPlaylist(unrelatedGroup, masterURL) + if err != nil { + t.Fatal(err) + } + if err := checkAudioRendition(master, PickBestVariant(master.Variants)); err != nil { + t.Fatalf("unreferenced audio group must not be rejected: %v", err) + } + + // A rendition group without URIs (muxed tracks) is fine. + groupWithoutURI := "#EXTM3U\n" + + "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",DEFAULT=YES\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=1000000,AUDIO=\"aud\"\nvideo/index.m3u8\n" + master, err = ParseMasterPlaylist(groupWithoutURI, masterURL) + if err != nil { + t.Fatal(err) + } + if err := checkAudioRendition(master, PickBestVariant(master.Variants)); err != nil { + t.Fatalf("audio group without URI must not be rejected: %v", err) + } +} diff --git a/internal/protocol/hls/testdata/media/README.md b/internal/protocol/hls/testdata/media/README.md new file mode 100644 index 00000000..10afcfb9 --- /dev/null +++ b/internal/protocol/hls/testdata/media/README.md @@ -0,0 +1,10 @@ +Media fixtures generated locally with FFmpeg 7.0.2 (testsrc + sine sources, +no third-party content): + +- sample-00.ts / sample-01.ts: one continuous MPEG-2 TS encode split at a + packet boundary, so continuity counters and PCR are consistent across the + segment join exactly like segments cut from a single real-time stream. +- fmp4-init.mp4: ftyp+moov init section of a fragmented MP4. +- fmp4-seg1.m4s .. fmp4-seg3.m4s: moof+mdat media fragments (styp omitted by + the muxer, which is legal) split at moof boundaries from one fragmented MP4 + (-movflags +frag_keyframe+empty_moov+default_base_moof -frag_duration 500000). diff --git a/internal/protocol/hls/testdata/media/fmp4-init.mp4 b/internal/protocol/hls/testdata/media/fmp4-init.mp4 new file mode 100644 index 00000000..0c758c0b Binary files /dev/null and b/internal/protocol/hls/testdata/media/fmp4-init.mp4 differ diff --git a/internal/protocol/hls/testdata/media/fmp4-seg1.m4s b/internal/protocol/hls/testdata/media/fmp4-seg1.m4s new file mode 100644 index 00000000..e2735d3e Binary files /dev/null and b/internal/protocol/hls/testdata/media/fmp4-seg1.m4s differ diff --git a/internal/protocol/hls/testdata/media/fmp4-seg2.m4s b/internal/protocol/hls/testdata/media/fmp4-seg2.m4s new file mode 100644 index 00000000..f6fb5cad Binary files /dev/null and b/internal/protocol/hls/testdata/media/fmp4-seg2.m4s differ diff --git a/internal/protocol/hls/testdata/media/fmp4-seg3.m4s b/internal/protocol/hls/testdata/media/fmp4-seg3.m4s new file mode 100644 index 00000000..8d1b41aa Binary files /dev/null and b/internal/protocol/hls/testdata/media/fmp4-seg3.m4s differ diff --git a/internal/protocol/hls/testdata/media/sample-00.ts b/internal/protocol/hls/testdata/media/sample-00.ts new file mode 100644 index 00000000..4e301f77 Binary files /dev/null and b/internal/protocol/hls/testdata/media/sample-00.ts differ diff --git a/internal/protocol/hls/testdata/media/sample-01.ts b/internal/protocol/hls/testdata/media/sample-01.ts new file mode 100644 index 00000000..7b230d5b Binary files /dev/null and b/internal/protocol/hls/testdata/media/sample-01.ts differ diff --git a/internal/protocol/hls/testdata/olemovienews.m3u8 b/internal/protocol/hls/testdata/olemovienews.m3u8 new file mode 100644 index 00000000..6c908400 --- /dev/null +++ b/internal/protocol/hls/testdata/olemovienews.m3u8 @@ -0,0 +1,283 @@ +#EXTM3U +#EXT-X-TARGETDURATION:20 +#EXT-X-ALLOW-CACHE:YES +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-VERSION:3 +#EXT-X-MEDIA-SEQUENCE:1 +#EXTINF:20.000, +seg-1-v1-a1.ts +#EXTINF:20.000, +seg-2-v1-a1.ts +#EXTINF:20.000, +seg-3-v1-a1.ts +#EXTINF:20.000, +seg-4-v1-a1.ts +#EXTINF:20.000, +seg-5-v1-a1.ts +#EXTINF:20.000, +seg-6-v1-a1.ts +#EXTINF:20.000, +seg-7-v1-a1.ts +#EXTINF:20.000, +seg-8-v1-a1.ts +#EXTINF:20.000, +seg-9-v1-a1.ts +#EXTINF:20.000, +seg-10-v1-a1.ts +#EXTINF:20.000, +seg-11-v1-a1.ts +#EXTINF:20.000, +seg-12-v1-a1.ts +#EXTINF:20.000, +seg-13-v1-a1.ts +#EXTINF:20.000, +seg-14-v1-a1.ts +#EXTINF:20.000, +seg-15-v1-a1.ts +#EXTINF:20.000, +seg-16-v1-a1.ts +#EXTINF:20.000, +seg-17-v1-a1.ts +#EXTINF:20.000, +seg-18-v1-a1.ts +#EXTINF:20.000, +seg-19-v1-a1.ts +#EXTINF:20.000, +seg-20-v1-a1.ts +#EXTINF:20.000, +seg-21-v1-a1.ts +#EXTINF:20.000, +seg-22-v1-a1.ts +#EXTINF:20.000, +seg-23-v1-a1.ts +#EXTINF:20.000, +seg-24-v1-a1.ts +#EXTINF:20.000, +seg-25-v1-a1.ts +#EXTINF:20.000, +seg-26-v1-a1.ts +#EXTINF:20.000, +seg-27-v1-a1.ts +#EXTINF:20.000, +seg-28-v1-a1.ts +#EXTINF:20.000, +seg-29-v1-a1.ts +#EXTINF:20.000, +seg-30-v1-a1.ts +#EXTINF:20.000, +seg-31-v1-a1.ts +#EXTINF:20.000, +seg-32-v1-a1.ts +#EXTINF:20.000, +seg-33-v1-a1.ts +#EXTINF:20.000, +seg-34-v1-a1.ts +#EXTINF:20.000, +seg-35-v1-a1.ts +#EXTINF:20.000, +seg-36-v1-a1.ts +#EXTINF:20.000, +seg-37-v1-a1.ts +#EXTINF:20.000, +seg-38-v1-a1.ts +#EXTINF:20.000, +seg-39-v1-a1.ts +#EXTINF:20.000, +seg-40-v1-a1.ts +#EXTINF:20.000, +seg-41-v1-a1.ts +#EXTINF:20.000, +seg-42-v1-a1.ts +#EXTINF:20.000, +seg-43-v1-a1.ts +#EXTINF:20.000, +seg-44-v1-a1.ts +#EXTINF:20.000, +seg-45-v1-a1.ts +#EXTINF:20.000, +seg-46-v1-a1.ts +#EXTINF:20.000, +seg-47-v1-a1.ts +#EXTINF:20.000, +seg-48-v1-a1.ts +#EXTINF:20.000, +seg-49-v1-a1.ts +#EXTINF:20.000, +seg-50-v1-a1.ts +#EXTINF:20.000, +seg-51-v1-a1.ts +#EXTINF:20.000, +seg-52-v1-a1.ts +#EXTINF:20.000, +seg-53-v1-a1.ts +#EXTINF:20.000, +seg-54-v1-a1.ts +#EXTINF:20.000, +seg-55-v1-a1.ts +#EXTINF:20.000, +seg-56-v1-a1.ts +#EXTINF:20.000, +seg-57-v1-a1.ts +#EXTINF:20.000, +seg-58-v1-a1.ts +#EXTINF:20.000, +seg-59-v1-a1.ts +#EXTINF:20.000, +seg-60-v1-a1.ts +#EXTINF:20.000, +seg-61-v1-a1.ts +#EXTINF:20.000, +seg-62-v1-a1.ts +#EXTINF:20.000, +seg-63-v1-a1.ts +#EXTINF:20.000, +seg-64-v1-a1.ts +#EXTINF:20.000, +seg-65-v1-a1.ts +#EXTINF:20.000, +seg-66-v1-a1.ts +#EXTINF:20.000, +seg-67-v1-a1.ts +#EXTINF:20.000, +seg-68-v1-a1.ts +#EXTINF:20.000, +seg-69-v1-a1.ts +#EXTINF:20.000, +seg-70-v1-a1.ts +#EXTINF:20.000, +seg-71-v1-a1.ts +#EXTINF:20.000, +seg-72-v1-a1.ts +#EXTINF:20.000, +seg-73-v1-a1.ts +#EXTINF:20.000, +seg-74-v1-a1.ts +#EXTINF:20.000, +seg-75-v1-a1.ts +#EXTINF:20.000, +seg-76-v1-a1.ts +#EXTINF:20.000, +seg-77-v1-a1.ts +#EXTINF:20.000, +seg-78-v1-a1.ts +#EXTINF:20.000, +seg-79-v1-a1.ts +#EXTINF:20.000, +seg-80-v1-a1.ts +#EXTINF:20.000, +seg-81-v1-a1.ts +#EXTINF:20.000, +seg-82-v1-a1.ts +#EXTINF:20.000, +seg-83-v1-a1.ts +#EXTINF:20.000, +seg-84-v1-a1.ts +#EXTINF:20.000, +seg-85-v1-a1.ts +#EXTINF:20.000, +seg-86-v1-a1.ts +#EXTINF:20.000, +seg-87-v1-a1.ts +#EXTINF:20.000, +seg-88-v1-a1.ts +#EXTINF:20.000, +seg-89-v1-a1.ts +#EXTINF:20.000, +seg-90-v1-a1.ts +#EXTINF:20.000, +seg-91-v1-a1.ts +#EXTINF:20.000, +seg-92-v1-a1.ts +#EXTINF:20.000, +seg-93-v1-a1.ts +#EXTINF:20.000, +seg-94-v1-a1.ts +#EXTINF:20.000, +seg-95-v1-a1.ts +#EXTINF:20.000, +seg-96-v1-a1.ts +#EXTINF:20.000, +seg-97-v1-a1.ts +#EXTINF:20.000, +seg-98-v1-a1.ts +#EXTINF:20.000, +seg-99-v1-a1.ts +#EXTINF:20.000, +seg-100-v1-a1.ts +#EXTINF:20.000, +seg-101-v1-a1.ts +#EXTINF:20.000, +seg-102-v1-a1.ts +#EXTINF:20.000, +seg-103-v1-a1.ts +#EXTINF:20.000, +seg-104-v1-a1.ts +#EXTINF:20.000, +seg-105-v1-a1.ts +#EXTINF:20.000, +seg-106-v1-a1.ts +#EXTINF:20.000, +seg-107-v1-a1.ts +#EXTINF:20.000, +seg-108-v1-a1.ts +#EXTINF:20.000, +seg-109-v1-a1.ts +#EXTINF:20.000, +seg-110-v1-a1.ts +#EXTINF:20.000, +seg-111-v1-a1.ts +#EXTINF:20.000, +seg-112-v1-a1.ts +#EXTINF:20.000, +seg-113-v1-a1.ts +#EXTINF:20.000, +seg-114-v1-a1.ts +#EXTINF:20.000, +seg-115-v1-a1.ts +#EXTINF:20.000, +seg-116-v1-a1.ts +#EXTINF:20.000, +seg-117-v1-a1.ts +#EXTINF:20.000, +seg-118-v1-a1.ts +#EXTINF:20.000, +seg-119-v1-a1.ts +#EXTINF:20.000, +seg-120-v1-a1.ts +#EXTINF:20.000, +seg-121-v1-a1.ts +#EXTINF:20.000, +seg-122-v1-a1.ts +#EXTINF:20.000, +seg-123-v1-a1.ts +#EXTINF:20.000, +seg-124-v1-a1.ts +#EXTINF:20.000, +seg-125-v1-a1.ts +#EXTINF:20.000, +seg-126-v1-a1.ts +#EXTINF:20.000, +seg-127-v1-a1.ts +#EXTINF:20.000, +seg-128-v1-a1.ts +#EXTINF:20.000, +seg-129-v1-a1.ts +#EXTINF:20.000, +seg-130-v1-a1.ts +#EXTINF:20.000, +seg-131-v1-a1.ts +#EXTINF:20.000, +seg-132-v1-a1.ts +#EXTINF:20.000, +seg-133-v1-a1.ts +#EXTINF:20.000, +seg-134-v1-a1.ts +#EXTINF:20.000, +seg-135-v1-a1.ts +#EXTINF:20.000, +seg-136-v1-a1.ts +#EXTINF:20.000, +seg-137-v1-a1.ts +#EXTINF:4.111, +seg-138-v1-a1.ts +#EXT-X-ENDLIST diff --git a/internal/protocol/http/config.go b/internal/protocol/http/config.go index fdabb292..7a09e59a 100644 --- a/internal/protocol/http/config.go +++ b/internal/protocol/http/config.go @@ -1,5 +1,8 @@ package http +// DefaultUserAgent is shared by HTTP downloads and extension HTTP requests. +const DefaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" + type config struct { UserAgent string `json:"userAgent"` Connections int `json:"connections"` diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index 4fb75ede..2de2c0de 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -448,6 +448,7 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error { if file.Name == "" || file.Name == "/" || file.Name == "." { file.Name = httpReq.URL.Hostname() } + file.Name = appendFilenameExtension(file.Name, resp.Header.Get("Content-Type")) res.Files = append(res.Files, file) f.meta.Res = res diff --git a/internal/protocol/http/fetcher_manager.go b/internal/protocol/http/fetcher_manager.go index 99d9b571..833a4b08 100644 --- a/internal/protocol/http/fetcher_manager.go +++ b/internal/protocol/http/fetcher_manager.go @@ -72,7 +72,7 @@ func (fm *FetcherManager) AutoRename() bool { func (fm *FetcherManager) DefaultConfig() any { return &config{ - UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + UserAgent: DefaultUserAgent, Connections: 16, } } diff --git a/internal/protocol/http/fetcher_test.go b/internal/protocol/http/fetcher_test.go index 7f07bedb..16d8403c 100644 --- a/internal/protocol/http/fetcher_test.go +++ b/internal/protocol/http/fetcher_test.go @@ -221,6 +221,30 @@ func TestFetcher_Resolve(t *testing.T) { }) } +func TestFetcher_ResolveFilenameFromContentType(t *testing.T) { + server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + w.Header().Set("Content-Type", "image/webp") + w.WriteHeader(gohttp.StatusOK) + })) + defer server.Close() + + fetcher := buildFetcher() + defer fetcher.Pause() + + const opaqueName = "OIP-C.u5nEXTXND3PJF0gNTgw5YgHaFP" + err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + opaqueName}, &base.Options{ + Name: test.DownloadName, + Path: test.Dir, + }) + if err != nil { + t.Fatal(err) + } + + if got := fetcher.meta.Res.Files[0].Name; got != opaqueName+".webp" { + t.Fatalf("resolved filename = %q, want %q", got, opaqueName+".webp") + } +} + func TestFetcher_ResolveWithHostHeader(t *testing.T) { listener := test.StartTestHostHeaderServer() defer listener.Close() diff --git a/internal/protocol/http/filename_parse_test.go b/internal/protocol/http/filename_parse_test.go index 7ce406f5..6945250f 100644 --- a/internal/protocol/http/filename_parse_test.go +++ b/internal/protocol/http/filename_parse_test.go @@ -136,3 +136,52 @@ func TestUnescapeHTMLEntities(t *testing.T) { }) } } + +func TestAppendFilenameExtension(t *testing.T) { + tests := []struct { + name string + filename string + contentType string + want string + }{ + { + name: "adds extension from content type", + filename: "download", + contentType: "image/png", + want: "download.png", + }, + { + name: "parses MIME parameters", + filename: "download", + contentType: "image/png; charset=binary", + want: "download.png", + }, + { + name: "preserves existing extension", + filename: "download.zip", + contentType: "application/zip", + want: "download.zip", + }, + { + name: "ignores invalid content type", + filename: "download", + contentType: "not a content type", + want: "download", + }, + { + name: "ignores unknown content type", + filename: "download", + contentType: "application/x-gopeed-unknown", + want: "download", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := appendFilenameExtension(tt.filename, tt.contentType) + if got != tt.want { + t.Errorf("appendFilenameExtension() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/protocol/http/helper.go b/internal/protocol/http/helper.go index 1292723e..30a27bac 100644 --- a/internal/protocol/http/helper.go +++ b/internal/protocol/http/helper.go @@ -12,6 +12,7 @@ import ( "net/http" "net/http/cookiejar" "net/url" + "path" "strings" "time" "unicode/utf8" @@ -314,6 +315,28 @@ func parseFilename(contentDisposition string) string { return parseFilenameFallback(contentDisposition) } +// appendFilenameExtension adds a MIME-derived extension when the resolved +// filename has no usable extension. Some image hosts use opaque path segments +// that look like extensions, even though the response Content-Type is the +// only reliable source for the file type. +func appendFilenameExtension(filename, contentType string) string { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return filename + } + + extensions, err := mime.ExtensionsByType(mediaType) + if err != nil || len(extensions) == 0 { + return filename + } + + ext := path.Ext(filename) + if len(ext) > 1 && len(ext) <= 20 { + return filename + } + return filename + extensions[0] +} + // parseFilenameExtended handles RFC 5987 extended notation (filename*=) // Format: filename*=charset'language'value (e.g., UTF-8”%E6%B5%8B%E8%AF%95.zip) func parseFilenameExtended(cd string) string { diff --git a/internal/webview/README.md b/internal/webview/README.md new file mode 100644 index 00000000..bd46c554 --- /dev/null +++ b/internal/webview/README.md @@ -0,0 +1,62 @@ +# Extension browser host configuration + +The downloader wraps each provider with the extension's stable identity before +constructing the runtime. `OpenOptions.ProfileID`, `DataPath`, and `ProxyURL` are +host-only fields. `parseOpenOptions` never reads them from extension JavaScript. +The extension API is unchanged. The Flutter host uses the fork's public +`WebViewProfile`, `InAppWebViewSettings.profileId`, and profile-scoped +`CookieManager` APIs; the library has no Gopeed-specific method-channel bridge. +Only the Gopeed extension JavaScript boundary keeps these options host-only. + +Profiles are persistent and shared across invocations/pages of the same extension. +Different identities use different native stores. Go providers use a SHA-256-based +path under `/webview`; Apple RPC hosts use a stable UUID data store; +Android uses a named WebView profile. Cookie operations select that same store, +including `clearCookies`. Closing an execution closes its pages, not its profile. +Uninstall closes the extension's pages, prevents its old runtimes from opening +new pages, and removes its native profile before deleting the extension record. +Cleanup failures abort uninstall and can be retried. Reinstallation starts with +empty browser data; an ordinary extension update keeps the existing profile. +Windows/Linux delete the profile directory; Apple removes the named WebKit store. +Android cannot delete a profile already loaded by the current process: when +DELETE_BROWSING_DATA is supported it clears all site data immediately, and records +an empty-profile deletion in SharedPreferences for the next process start. A +reinstallation may reuse that cleared profile and cancels the pending deletion. +If the full-data clearing API is unavailable, uninstall fails with a restart +instruction; the pending profile is deleted at startup before it can be loaded. +No removal operation is exposed through extension JavaScript. + +Existing shared browser data is not copied into every extension's new profile. + +The downloader lazily owns one loopback forward proxy with HTTP and SOCKS5 +listeners. Windows/Linux/Android use HTTP; Apple uses SOCKS5 because its HTTP +CONNECT configuration may bypass plain HTTP. The forwarder reads the current +Gopeed proxy config for each new request/tunnel and supports direct, system, +HTTP(S) upstream proxies with Basic authentication, and authenticated SOCKS5. +Credentials remain in Go and are never sent over the WebView RPC. + +Changes affect new requests/connections. Existing CONNECT/SOCKS tunnels keep their +route until closed; opening another page in the same profile does not reset it. +SOCKS carries host/port rather than a full URL: system proxy/PAC selection for +Apple is host-based, with port 80 treated as HTTP and other ports as HTTPS. +Path-dependent PAC rules cannot be reproduced from a SOCKS connection. + +Apple requires macOS 14 / iOS 17. Android checks MULTI_PROFILE and PROXY_OVERRIDE. +Unsupported hosts fail explicitly instead of silently sharing browser storage or +ignoring the proxy. + +## Validation + +- `go test -race ./internal/webview/proxy ./pkg/download/engine/webview` +- `go test ./pkg/download -run 'TestWebViewProfileBound|TestDownloader_ExtensionRuntimeWebView'` +- Windows/Linux: `go test -tags webview -v ./internal/webview/goprovider` (Linux: run under `xvfb-run -a`). +- macOS: start the normal Flutter app, then run `go test -tags webview -v ./internal/webview/rpcprovider` with its socket address. See `rpcprovider/README.md`. +- In webview_go: `go test -tags webview_integration -v -count=1 .` (Linux: run under `xvfb-run -a`). + +The shared provider contract covers HTTP/HTTPS proxy routing, concurrent pages, +HttpOnly cookies, LocalStorage, IndexedDB, profile-scoped clearing, reopening, +and removal without affecting another profile. HTTPS routing is checked with a +rejecting upstream tunnel; no test-only certificate bypass is added to the app. +Go proxy unit tests separately cover successful TLS forwarding and authentication. +These cases run in the existing `test.yml` WebView jobs; existing mobile build jobs +compile the Android/iOS bridge. No separate Flutter test application is required. diff --git a/internal/webview/goprovider/mainthread_linux.go b/internal/webview/goprovider/mainthread_linux.go new file mode 100644 index 00000000..3e0a09da --- /dev/null +++ b/internal/webview/goprovider/mainthread_linux.go @@ -0,0 +1,47 @@ +//go:build linux && cgo + +package goprovider + +/* +#cgo pkg-config: gtk+-3.0 +#include +static void pumpGTK(void) { + // Bound each batch so Go creation/removal requests cannot be starved. + for (int i = 0; i < 64 && g_main_context_pending(NULL); ++i) + g_main_context_iteration(NULL, FALSE); +} +*/ +import "C" + +import ( + "runtime" + "sync" + "time" +) + +var gtkThreadOnce sync.Once +var gtkThreadTasks = make(chan func()) + +func RunMainThreadLoop(run func() int) int { return run() } + +func postMainThreadTask(task func()) bool { + gtkThreadOnce.Do(func() { + go func() { + runtime.LockOSThread() + // WebKit must always use its original UI thread, including after + // the last page closes. A page cannot stop this shared event loop. + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + select { + case task := <-gtkThreadTasks: + task() + case <-ticker.C: + } + C.pumpGTK() + } + }() + }) + gtkThreadTasks <- task + return true +} diff --git a/internal/webview/goprovider/mainthread_other.go b/internal/webview/goprovider/mainthread_other.go index aa3ee4a1..45ad0255 100644 --- a/internal/webview/goprovider/mainthread_other.go +++ b/internal/webview/goprovider/mainthread_other.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin && (!linux || !cgo) package goprovider diff --git a/internal/webview/goprovider/provider.go b/internal/webview/goprovider/provider.go index a81c20df..c9d06a5e 100644 --- a/internal/webview/goprovider/provider.go +++ b/internal/webview/goprovider/provider.go @@ -105,7 +105,15 @@ func (p *pageWrapper) start() error { defer runtimepkg.UnlockOSThread() var w webview.WebView - if p.opts.Headless { + if p.opts.DataPath != "" { + var err error + w, err = webview.NewWithOptions(webview.Options{Debug: p.opts.Debug, Headless: p.opts.Headless, DataPath: p.opts.DataPath, ProxyURL: p.opts.ProxyURL}) + if err != nil { + p.ready <- err + close(p.done) + return + } + } else if p.opts.Headless { w = webview.NewHeadless(p.opts.Debug) } else { w = webview.New(p.opts.Debug) @@ -127,12 +135,18 @@ func (p *pageWrapper) start() error { if err := w.Bind(p.callbackName, func(payload string) error { return p.handleCallback(payload) }); err != nil { + w.Destroy() p.ready <- err + close(p.done) return } applyWindowOptions(w, p.opts) w.SetHtml(buildBootstrapHTML(p.callbackName, p.readyID)) + if runtimepkg.GOOS == "linux" { + // All GTK pages share the process-wide event loop below. + return + } w.Run() w.Destroy() close(p.done) @@ -318,7 +332,12 @@ func (p *pageWrapper) Close() error { // the quit message lands on the correct queue. terminateDone := make(chan struct{}) w.Dispatch(func() { - w.Terminate() + if runtimepkg.GOOS == "linux" { + w.Destroy() + close(p.done) + } else { + w.Terminate() + } close(terminateDone) }) <-terminateDone @@ -610,3 +629,12 @@ func firstNonEmpty(values ...string) string { } return "" } + +func (p *Provider) RemoveProfile(profileID, dataPath string) error { + done := make(chan error, 1) + run := func() { done <- webview.RemoveProfile(dataPath) } + if !postMainThreadTask(run) { + run() + } + return <-done +} diff --git a/internal/webview/goprovider/provider_integration_test.go b/internal/webview/goprovider/provider_integration_test.go index 92f226e7..e6d9e63e 100644 --- a/internal/webview/goprovider/provider_integration_test.go +++ b/internal/webview/goprovider/provider_integration_test.go @@ -13,3 +13,5 @@ func TestProviderContract(t *testing.T) { CookieDomainMode: integrationtest.CookieDomainModeRequired, }) } + +func TestProviderProfiles(t *testing.T) { integrationtest.RunProfileContract(t, New()) } diff --git a/internal/webview/integrationtest/contract.go b/internal/webview/integrationtest/contract.go index 1a508e35..364fe5c2 100644 --- a/internal/webview/integrationtest/contract.go +++ b/internal/webview/integrationtest/contract.go @@ -1,10 +1,14 @@ package integrationtest import ( + "crypto/sha256" "fmt" + webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy" "net/http" "net/http/httptest" "net/url" + "path/filepath" + "runtime" "strings" "testing" "time" @@ -219,7 +223,19 @@ func openTestPage(t *testing.T, provider enginewebview.Provider, opts enginewebv })) t.Cleanup(server.Close) - runtime := enginewebview.NewRuntime(provider, true) + proxy, err := webviewproxy.New(nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { proxy.Close() }) + path := filepath.Join(t.TempDir(), "profile") + sum := sha256.Sum256([]byte(path)) + profileID := fmt.Sprintf("%x-%x-%x-%x-%x", sum[:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16]) + proxyURL := proxy.URL() + if runtime.GOOS == "darwin" { + proxyURL = proxy.SOCKSURL() + } + runtime := enginewebview.NewRuntime(&configuredOpener{provider: provider, profileID: profileID, dataPath: path, proxyURL: proxyURL}, true) page, err := runtime.Open(map[string]any{ "headless": opts.Headless, "debug": opts.Debug, @@ -232,7 +248,14 @@ func openTestPage(t *testing.T, provider enginewebview.Provider, opts enginewebv t.Fatalf("failed to open webview: %v", err) } t.Cleanup(func() { - _ = runtime.Close() + if err := runtime.Close(); err != nil { + t.Errorf("close contract page: %v", err) + } + if remover, ok := provider.(enginewebview.ProfileRemover); ok { + if err := remover.RemoveProfile(profileID, path); err != nil { + t.Errorf("remove contract profile: %v", err) + } + } }) return page, server, server.URL @@ -380,3 +403,15 @@ func hasCookie(cookies []enginewebview.Cookie, name string, value string) bool { } return false } + +type configuredOpener struct { + provider enginewebview.Provider + profileID, dataPath, proxyURL string +} + +func (o *configuredOpener) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) { + opts.ProfileID = o.profileID + opts.DataPath = o.dataPath + opts.ProxyURL = o.proxyURL + return o.provider.Open(opts) +} diff --git a/internal/webview/integrationtest/profiles.go b/internal/webview/integrationtest/profiles.go new file mode 100644 index 00000000..72d297ba --- /dev/null +++ b/internal/webview/integrationtest/profiles.go @@ -0,0 +1,247 @@ +package integrationtest + +import ( + "crypto/sha256" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "reflect" + "runtime" + "sync/atomic" + "testing" + "time" + + webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy" + webview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" +) + +// RunProfileContract exercises the same host API through both native and RPC +// providers. It never bypasses the host bridge with direct Flutter plugin calls. +func RunProfileContract(t *testing.T, provider webview.Provider) { + t.Helper() + waitForProviderAvailable(t, provider, defaultAvailabilityTimeout) + remover, ok := provider.(webview.ProfileRemover) + if !ok { + t.Fatal("provider does not implement host profile removal") + } + proxyURL, secureHits := profileProxy(t) + a := newContractProfile(t, provider, remover, proxyURL) + b := newContractProfile(t, provider, remover, proxyURL) + first := a.open(t) + assertProfileValue(t, first, `async () => { localStorage.setItem('owner','a'); return 'a'; }`, "a") + assertProfileValue(t, first, profileDatabaseScript(true), "saved") + if err := first.SetCookie(webview.Cookie{Name: "session", Value: "a", Domain: "profiles.invalid", Path: "/", HTTPOnly: true, Expires: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + second := b.open(t) + assertProfileValue(t, second, `() => localStorage.getItem('owner')`, nil) + assertProfileValue(t, second, profileDatabaseScript(false), nil) + assertProfileCookies(t, second, "", "new profile B") + assertProfileValue(t, second, `() => { localStorage.setItem('owner','b'); return 'b'; }`, "b") + if err := second.SetCookie(webview.Cookie{Name: "session", Value: "b", Domain: "profiles.invalid", Path: "/", HTTPOnly: true}); err != nil { + t.Fatal(err) + } + shared := a.open(t) + assertProfileValue(t, shared, `() => localStorage.getItem('owner')`, "a") + assertProfileValue(t, shared, profileDatabaseScript(false), "saved") + assertProfileCookies(t, shared, "a", "shared profile A") + storedCookies, err := shared.GetCookies() + if err != nil || len(storedCookies) != 1 || storedCookies[0].Expires.Before(time.Now()) { + t.Fatalf("persistent cookie lost its expiration: %#v, error %v", storedCookies, err) + } + assertProfileValue(t, shared, `() => document.cookie`, "") // HttpOnly stays hidden. + if err := second.ClearCookies(); err != nil { + t.Fatal(err) + } + assertProfileCookies(t, first, "a", "clearing B leaves A intact") + if err := second.SetCookie(webview.Cookie{Name: "session", Value: "b", Domain: "profiles.invalid", Path: "/", HTTPOnly: true}); err != nil { + t.Fatal(err) + } + a.close(t) + reopened := a.open(t) + assertProfileValue(t, reopened, `() => localStorage.getItem('owner')`, "a") + assertProfileValue(t, reopened, profileDatabaseScript(false), "saved") + assertProfileCookies(t, reopened, "a", "reopened profile A") + // Include a session cookie immediately before deletion to catch stale + // in-process CookieStore caches when the same profile is recreated. + if err := reopened.SetCookie(webview.Cookie{Name: "session", Value: "remove-me", Domain: "profiles.invalid", Path: "/", HTTPOnly: true}); err != nil { + t.Fatal(err) + } + a.close(t) + a.remove(t) + a.remove(t) // Retrying a successful uninstall is harmless. + reinstalled := a.open(t) + assertProfileValue(t, reinstalled, `() => localStorage.getItem('owner')`, nil) + assertProfileValue(t, reinstalled, profileDatabaseScript(false), nil) + assertProfileCookies(t, reinstalled, "", "reinstalled profile A") + assertProfileValue(t, second, `() => localStorage.getItem('owner')`, "b") + assertProfileCookies(t, second, "b", "B after removing A") + // Refuse the HTTPS tunnel deliberately. Seeing it at our proxy proves HTTPS + // routing without adding a certificate-validation bypass to the test host. + // WebView2 may commit its built-in error page and report navigation as + // complete. The invariant here is that the HTTPS tunnel reaches the proxy. + _ = reinstalled.Goto("https://profiles.invalid/", webview.GotoOptions{TimeoutMS: 5000}) + deadline := time.Now().Add(5 * time.Second) + for secureHits.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if secureHits.Load() == 0 { + t.Fatal("HTTPS bypassed the host proxy") + } +} + +type contractProfile struct { + provider webview.Provider + remover webview.ProfileRemover + options webview.OpenOptions + pages []webview.Page +} + +func newContractProfile(t *testing.T, provider webview.Provider, remover webview.ProfileRemover, proxyURL string) *contractProfile { + t.Helper() + path := filepath.Join(t.TempDir(), "profile") + hash := sha256.Sum256([]byte(path)) + id := fmt.Sprintf("%x-%x-%x-%x-%x", hash[:4], hash[4:6], hash[6:8], hash[8:10], hash[10:16]) + p := &contractProfile{provider: provider, remover: remover, options: webview.OpenOptions{ProfileID: id, DataPath: path, ProxyURL: proxyURL, Headless: true, Title: "Gopeed profile contract"}} + t.Cleanup(func() { p.close(t); p.remove(t) }) + return p +} +func (p *contractProfile) open(t *testing.T) webview.Page { + t.Helper() + page, err := p.provider.Open(p.options) + if err != nil { + t.Fatal(err) + } + p.pages = append(p.pages, page) + if err := page.Goto("http://profiles.invalid/", webview.GotoOptions{TimeoutMS: 10000}); err != nil { + t.Fatal(err) + } + return page +} +func (p *contractProfile) close(t *testing.T) { + t.Helper() + for _, page := range p.pages { + if err := page.Close(); err != nil { + t.Errorf("close profile page: %v", err) + } + } + p.pages = nil +} +func (p *contractProfile) remove(t *testing.T) { + t.Helper() + if err := p.remover.RemoveProfile(p.options.ProfileID, p.options.DataPath); err != nil { + t.Errorf("remove profile: %v", err) + } +} +func assertProfileValue(t *testing.T, page webview.Page, script string, want any) { + t.Helper() + got, err := page.Execute(script) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("script %s: got %#v, want %#v", script, got, want) + } +} +func assertProfileCookies(t *testing.T, page webview.Page, want, stage string) { + t.Helper() + // Native cookie managers propagate updates across browser processes + // asynchronously. Wait for the expected state rather than assuming the + // first IPC read already includes the preceding write. + deadline := time.Now().Add(5 * time.Second) + for { + cookies, err := page.GetCookies() + if err != nil { + t.Fatalf("%s: %v", stage, err) + } + matches := len(cookies) == 0 && want == "" || len(cookies) == 1 && want != "" && cookies[0].Name == "session" && cookies[0].Value == want && cookies[0].HTTPOnly + if matches { + return + } + if time.Now().After(deadline) { + t.Fatalf("%s: cookies %#v, want %q", stage, cookies, want) + } + time.Sleep(20 * time.Millisecond) + } +} +func profileDatabaseScript(write bool) string { + operation := "store.get('owner')" + if write { + operation = "store.put('saved','owner')" + } + return fmt.Sprintf(`async () => await new Promise((resolve,reject)=>{ + const request=indexedDB.open('profile-contract',1); + request.onupgradeneeded=()=>request.result.createObjectStore('state'); + request.onerror=()=>reject(String(request.error)); + request.onsuccess=()=>{ + const db=request.result, tx=db.transaction('state','readwrite'), store=tx.objectStore('state'); + const op=%s; let value; + op.onsuccess=()=>value=%s; + tx.oncomplete=()=>{db.close();resolve(value===undefined?null:value)}; + tx.onerror=()=>{db.close();reject(String(tx.error))}; + }; +})`, operation, map[bool]string{true: "'saved'", false: "op.result"}[write]) +} + +func profileProxy(t *testing.T) (string, *atomic.Int32) { + t.Helper() + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Header().Set("Cache-Control", "no-store") + fmt.Fprint(w, "Profile contract") + })) + t.Cleanup(origin.Close) + secureHits := &atomic.Int32{} + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect { + origin.Config.Handler.ServeHTTP(w, r) + return + } + if r.Host == "profiles.invalid:443" { + secureHits.Add(1) + http.Error(w, "HTTPS routing fixture rejects tunnels", http.StatusBadGateway) + return + } + if r.Host != "profiles.invalid:80" { + http.Error(w, "unexpected target", http.StatusBadGateway) + return + } + remote, err := net.DialTimeout("tcp", origin.Listener.Addr().String(), 5*time.Second) + if err != nil { + http.Error(w, "origin unavailable", http.StatusBadGateway) + return + } + defer remote.Close() + client, buffer, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer client.Close() + if _, err := buffer.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { + return + } + if err := buffer.Flush(); err != nil { + return + } + go func() { io.Copy(remote, buffer); remote.Close() }() + io.Copy(client, remote) + })) + t.Cleanup(upstream.Close) + upstreamURL, err := url.Parse(upstream.URL) + if err != nil { + t.Fatal(err) + } + proxy, err := webviewproxy.New(func(*http.Request) (*url.URL, error) { return upstreamURL, nil }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { proxy.Close() }) + if runtime.GOOS == "darwin" { + return proxy.SOCKSURL(), secureHits + } + return proxy.URL(), secureHits +} diff --git a/internal/webview/proxy/server.go b/internal/webview/proxy/server.go new file mode 100644 index 00000000..17697d40 --- /dev/null +++ b/internal/webview/proxy/server.go @@ -0,0 +1,341 @@ +// Package proxy provides the host-owned WebView forward proxy. Browser engines +// always use this endpoint; Gopeed decides the upstream route for each request. +package proxy + +import ( + "bufio" + "context" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/armon/go-socks5" + xproxy "golang.org/x/net/proxy" +) + +type Selector func(*http.Request) (*url.URL, error) + +type Server struct { + listener net.Listener + socksListener net.Listener + server *http.Server + transport *http.Transport + selectProxy Selector + mu sync.Mutex + connections map[io.Closer]struct{} + closed bool +} + +func New(selectProxy Selector) (*Server, error) { + if selectProxy == nil { + selectProxy = func(*http.Request) (*url.URL, error) { return nil, nil } + } + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return nil, err + } + s := &Server{listener: listener, selectProxy: selectProxy, connections: make(map[io.Closer]struct{})} + s.transport = &http.Transport{Proxy: selectProxy, DisableKeepAlives: true, TLSHandshakeTimeout: 15 * time.Second, ResponseHeaderTimeout: 30 * time.Second} + s.server = &http.Server{Handler: s, ReadHeaderTimeout: 15 * time.Second} + s.socksListener, err = net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + listener.Close() + return nil, err + } + socks, err := socks5.New(&socks5.Config{Logger: log.New(io.Discard, "", 0), Resolver: unresolvedDNS{}, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + _, port, _ := net.SplitHostPort(address) + scheme := "https" + if port == "80" { + scheme = "http" + } + request := &http.Request{URL: &url.URL{Scheme: scheme, Host: address}, Header: make(http.Header)} + upstream, err := selectProxy(request) + if err != nil { + return nil, err + } + timed, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + conn, err := dialTunnel(timed, address, upstream) + if err != nil { + return nil, err + } + return s.track(conn) + }}) + if err != nil { + listener.Close() + s.socksListener.Close() + return nil, err + } + go s.server.Serve(listener) + go socks.Serve(&trackedListener{Listener: s.socksListener, server: s}) + return s, nil +} + +func (s *Server) URL() string { return "http://" + s.listener.Addr().String() } + +func (s *Server) SOCKSURL() string { return "socks5://" + s.socksListener.Addr().String() } + +func (s *Server) Close() error { + s.mu.Lock() + s.closed = true + connections := make([]io.Closer, 0, len(s.connections)) + for c := range s.connections { + connections = append(connections, c) + } + s.mu.Unlock() + for _, c := range connections { + c.Close() + } + s.socksListener.Close() + s.transport.CloseIdleConnections() + return s.server.Close() +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect { + s.connect(w, r) + return + } + if !r.URL.IsAbs() || (r.URL.Scheme != "http" && r.URL.Scheme != "https") { + http.Error(w, "absolute HTTP URL required", http.StatusBadRequest) + return + } + out := r.Clone(r.Context()) + out.RequestURI = "" + upgrade := out.Header.Get("Upgrade") + stripHopHeaders(out.Header) + if upgrade != "" { + out.Header.Set("Connection", "Upgrade") + out.Header.Set("Upgrade", upgrade) + } + resp, err := s.transport.RoundTrip(out) + if err != nil { + http.Error(w, "upstream request failed", http.StatusBadGateway) + return + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusSwitchingProtocols { + remote, ok := resp.Body.(io.ReadWriteCloser) + if !ok { + http.Error(w, "invalid upstream upgrade", 502) + return + } + client, buffer, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer client.Close() + resp.Body = nil + if err := resp.Write(buffer); err != nil { + return + } + if err := buffer.Flush(); err != nil { + return + } + s.bridge(client, buffer, remote) + return + } + stripHopHeaders(resp.Header) + for k, v := range resp.Header { + w.Header()[k] = v + } + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) +} + +func stripHopHeaders(h http.Header) { + for _, key := range strings.Split(h.Get("Connection"), ",") { + h.Del(strings.TrimSpace(key)) + } + for _, key := range []string{"Connection", "Proxy-Connection", "Proxy-Authorization", "Proxy-Authenticate", "Keep-Alive", "TE", "Trailer", "Transfer-Encoding", "Upgrade"} { + h.Del(key) + } +} + +func (s *Server) connect(w http.ResponseWriter, r *http.Request) { + if _, _, err := net.SplitHostPort(r.Host); err != nil { + http.Error(w, "invalid tunnel destination", 400) + return + } + target := r.Clone(r.Context()) + target.URL = &url.URL{Scheme: "https", Host: r.Host} + upstream, err := s.selectProxy(target) + if err != nil { + http.Error(w, "proxy selection failed", 502) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + remote, err := dialTunnel(ctx, r.Host, upstream) + if err != nil { + http.Error(w, "upstream connection failed", 502) + return + } + defer remote.Close() + client, buffer, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer client.Close() + if _, err := buffer.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { + return + } + if err := buffer.Flush(); err != nil { + return + } + s.bridge(client, buffer, remote) +} + +func (s *Server) bridge(client net.Conn, buffer *bufio.ReadWriter, remote io.ReadWriteCloser) { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.connections[client] = struct{}{} + s.connections[remote] = struct{}{} + s.mu.Unlock() + defer func() { s.mu.Lock(); delete(s.connections, client); delete(s.connections, remote); s.mu.Unlock() }() + + done := make(chan struct{}) + go func() { io.Copy(remote, buffer); remote.Close(); close(done) }() + io.Copy(client, remote) + client.Close() + <-done +} + +type bufferedConn struct { + net.Conn + reader *bufio.Reader +} + +func (c *bufferedConn) Read(p []byte) (int, error) { return c.reader.Read(p) } + +func dialTunnel(ctx context.Context, target string, upstream *url.URL) (net.Conn, error) { + dialer := &net.Dialer{} + if upstream == nil { + return dialer.DialContext(ctx, "tcp", target) + } + if upstream.Scheme == "socks5" || upstream.Scheme == "socks5h" { + u := *upstream + u.Scheme = "socks5" + d, err := xproxy.FromURL(&u, dialer) + if err != nil { + return nil, err + } + return d.(xproxy.ContextDialer).DialContext(ctx, "tcp", target) + } + if upstream.Scheme != "http" && upstream.Scheme != "https" { + return nil, errors.New("unsupported proxy scheme") + } + host := upstream.Host + if upstream.Port() == "" { + port := "80" + if upstream.Scheme == "https" { + port = "443" + } + host = net.JoinHostPort(upstream.Hostname(), port) + } + conn, err := dialer.DialContext(ctx, "tcp", host) + if err != nil { + return nil, err + } + ok := false + defer func() { + if !ok { + conn.Close() + } + }() + if deadline, exists := ctx.Deadline(); exists { + conn.SetDeadline(deadline) + } + if upstream.Scheme == "https" { + secure := tls.Client(conn, &tls.Config{ServerName: upstream.Hostname(), MinVersion: tls.VersionTLS12}) + if err := secure.HandshakeContext(ctx); err != nil { + return nil, err + } + conn = secure + } + request := &http.Request{Method: http.MethodConnect, URL: &url.URL{Opaque: target}, Host: target, Header: make(http.Header)} + if upstream.User != nil { + password, _ := upstream.User.Password() + request.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(upstream.User.Username()+":"+password))) + } + if err := request.Write(conn); err != nil { + return nil, err + } + reader := bufio.NewReader(conn) + response, err := http.ReadResponse(reader, request) + if err != nil { + return nil, err + } + if response.StatusCode != 200 { + response.Body.Close() + return nil, fmt.Errorf("upstream CONNECT status %d", response.StatusCode) + } + conn.SetDeadline(time.Time{}) + ok = true + return &bufferedConn{Conn: conn, reader: reader}, nil +} + +// Leave hostname resolution to the selected upstream (SOCKS remote DNS / CONNECT). +type unresolvedDNS struct{} + +func (unresolvedDNS) Resolve(ctx context.Context, name string) (context.Context, net.IP, error) { + return ctx, nil, nil +} + +type trackedListener struct { + net.Listener + server *Server +} + +func (l *trackedListener) Accept() (net.Conn, error) { + c, err := l.Listener.Accept() + if err != nil { + return nil, err + } + return l.server.track(c) +} + +type trackedConn struct { + net.Conn + server *Server +} + +func (c *trackedConn) Close() error { + err := c.Conn.Close() + c.server.mu.Lock() + delete(c.server.connections, c) + c.server.mu.Unlock() + return err +} +func (s *Server) track(c net.Conn) (net.Conn, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + c.Close() + return nil, net.ErrClosed + } + tracked := &trackedConn{Conn: c, server: s} + s.connections[tracked] = struct{}{} + return tracked, nil +} + +func (c *trackedConn) CloseWrite() error { return closeWrite(c.Conn) } +func (c *bufferedConn) CloseWrite() error { return closeWrite(c.Conn) } +func closeWrite(c net.Conn) error { + if writer, ok := c.(interface{ CloseWrite() error }); ok { + return writer.CloseWrite() + } + return c.Close() +} diff --git a/internal/webview/proxy/server_test.go b/internal/webview/proxy/server_test.go new file mode 100644 index 00000000..217de9e8 --- /dev/null +++ b/internal/webview/proxy/server_test.go @@ -0,0 +1,315 @@ +package proxy + +import ( + "bufio" + "context" + "crypto/tls" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/armon/go-socks5" +) + +func TestRejectedProxyRequests(t *testing.T) { + secret := "upstream-password" + for _, tc := range []struct { + name, method, target string + status int + }{ + {"relative URL", "GET", "/relative", 400}, + {"unsupported URL", "GET", "ftp://example.com/file", 400}, + {"invalid CONNECT", "CONNECT", "example.com", 400}, + {"HTTP selection failure", "GET", "http://example.com/", 502}, + {"CONNECT selection failure", "CONNECT", "example.com:443", 502}, + } { + t.Run(tc.name, func(t *testing.T) { + s := &Server{selectProxy: func(*http.Request) (*url.URL, error) { return nil, errors.New(secret) }} + s.transport = &http.Transport{Proxy: s.selectProxy} + defer s.transport.CloseIdleConnections() + response := httptest.NewRecorder() + s.ServeHTTP(response, httptest.NewRequest(tc.method, tc.target, nil)) + if response.Code != tc.status || strings.Contains(response.Body.String(), secret) { + t.Fatalf("unexpected proxy rejection: %d %q", response.Code, response.Body.String()) + } + }) + } +} + +func TestTunnelRejectsUnsafeUpstreams(t *testing.T) { + rejected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "authentication required", http.StatusProxyAuthRequired) + })) + defer rejected.Close() + secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("untrusted TLS proxy received a request") + })) + defer secure.Close() + for _, upstream := range []string{"ftp://127.0.0.1", rejected.URL, secure.URL} { + t.Run(upstream, func(t *testing.T) { + selected, err := url.Parse(upstream) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + conn, err := dialTunnel(ctx, "example.com:443", selected) + if conn != nil { + conn.Close() + } + if err == nil { + t.Fatal("unsafe upstream unexpectedly accepted") + } + }) + } +} + +func TestRoutesAndAuthentication(t *testing.T) { + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Proxy-Authorization") != "" { + t.Error("proxy credentials leaked to origin") + } + w.Write([]byte("origin")) + })) + defer origin.Close() + secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("secure")) })) + defer secure.Close() + upstream, err := New(func(*http.Request) (*url.URL, error) { return nil, nil }) + if err != nil { + t.Fatal(err) + } + defer upstream.Close() + var authenticated atomic.Int32 + authProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Proxy-Authorization") != "Basic dXNlcjpwYXNz" { + http.Error(w, "authentication required", 407) + return + } + authenticated.Add(1) + upstream.ServeHTTP(w, r) + })) + defer authProxy.Close() + socks, err := socks5.New(&socks5.Config{Credentials: socks5.StaticCredentials{"user": "pass"}}) + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go socks.Serve(listener) + for _, route := range []string{"", authProxy.URL, "socks5://" + listener.Addr().String()} { + t.Run(route, func(t *testing.T) { + var selected *url.URL + if route != "" { + selected, _ = url.Parse(route) + selected.User = url.UserPassword("user", "pass") + } + local, err := New(func(r *http.Request) (*url.URL, error) { return selected, nil }) + if err != nil { + t.Fatal(err) + } + defer local.Close() + proxyURL, _ := url.Parse(local.URL()) + transport := &http.Transport{Proxy: http.ProxyURL(proxyURL), TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 5 * time.Second} + for _, test := range []struct{ url, want string }{{origin.URL, "origin"}, {secure.URL, "secure"}} { + response, err := client.Get(test.url) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(response.Body) + response.Body.Close() + if string(body) != test.want { + t.Fatalf("got status %d, body %q", response.StatusCode, body) + } + } + }) + } + if authenticated.Load() != 2 { + t.Fatalf("expected authenticated HTTP and CONNECT, got %d", authenticated.Load()) + } +} + +func TestRoutingChangesAndFailureDoesNotBypassProxy(t *testing.T) { + var hits atomic.Int32 + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1); w.Write([]byte("direct")) })) + defer origin.Close() + var useProxy atomic.Bool + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("proxied")) })) + upstreamURL, _ := url.Parse(upstream.URL) + local, err := New(func(*http.Request) (*url.URL, error) { + if useProxy.Load() { + return upstreamURL, nil + } + return nil, nil + }) + if err != nil { + t.Fatal(err) + } + defer local.Close() + endpoint, _ := url.Parse(local.URL()) + transport := &http.Transport{Proxy: http.ProxyURL(endpoint)} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 3 * time.Second} + for _, want := range []string{"direct", "proxied", "upstream request failed\n"} { + resp, err := client.Get(origin.URL) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if string(body) != want { + t.Fatalf("got %q want %q", body, want) + } + if want == "direct" { + useProxy.Store(true) + } else if want == "proxied" { + upstream.Close() + } + } + if hits.Load() != 1 { + t.Fatal("failed proxy must not fall back to direct") + } +} + +func TestCloseTerminatesTunnels(t *testing.T) { + echo, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer echo.Close() + go func() { + c, e := echo.Accept() + if e == nil { + defer c.Close() + io.Copy(c, c) + } + }() + s, err := New(func(*http.Request) (*url.URL, error) { return nil, nil }) + if err != nil { + t.Fatal(err) + } + endpoint, _ := url.Parse(s.URL()) + c, err := dialTunnel(context.Background(), echo.Addr().String(), endpoint) + if err != nil { + t.Fatal(err) + } + defer c.Close() + c.SetDeadline(time.Now().Add(3 * time.Second)) + c.Write([]byte("hello\n")) + line, err := bufio.NewReader(c).ReadString('\n') + if err != nil || line != "hello\n" { + t.Fatalf("echo %q %v", line, err) + } + s.Close() + if _, err := c.Read(make([]byte, 1)); err == nil { + t.Fatal("tunnel stayed open") + } +} + +func TestSOCKSIngressPreservesRemoteDNS(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "CONNECT" || r.Host != "unresolved.invalid:80" { + t.Errorf("lost destination: %s %s", r.Method, r.Host) + } + c, b, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer c.Close() + b.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n") + b.Flush() + request, err := http.ReadRequest(b.Reader) + if err != nil { + t.Error(err) + return + } + request.Body.Close() + b.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\nproxied") + b.Flush() + })) + defer upstream.Close() + target, _ := url.Parse(upstream.URL) + local, err := New(func(r *http.Request) (*url.URL, error) { + if r.URL.Host != "unresolved.invalid:80" { + t.Errorf("selector lost hostname: %s", r.URL) + } + return target, nil + }) + if err != nil { + t.Fatal(err) + } + defer local.Close() + endpoint, _ := url.Parse(local.SOCKSURL()) + transport := &http.Transport{Proxy: http.ProxyURL(endpoint)} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 5 * time.Second} + response, err := client.Get("http://unresolved.invalid/") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + if string(body) != "proxied" { + t.Fatalf("unexpected body %q", body) + } +} + +func TestHTTPUpgradeIsForwarded(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Upgrade") != "fixture" { + http.Error(w, "upgrade lost", 400) + return + } + c, b, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer c.Close() + b.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: fixture\r\n\r\n") + b.Flush() + line, err := b.ReadString('\n') + if err != nil { + return + } + b.WriteString(line) + b.Flush() + })) + defer upstream.Close() + local, err := New(nil) + if err != nil { + t.Fatal(err) + } + defer local.Close() + endpoint, _ := url.Parse(local.URL()) + c, err := net.Dial("tcp", endpoint.Host) + if err != nil { + t.Fatal(err) + } + defer c.Close() + c.SetDeadline(time.Now().Add(3 * time.Second)) + io.WriteString(c, "GET "+upstream.URL+"/ HTTP/1.1\r\nHost: fixture\r\nConnection: Upgrade\r\nUpgrade: fixture\r\n\r\n") + reader := bufio.NewReader(c) + response, err := http.ReadResponse(reader, nil) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != 101 { + t.Fatalf("upgrade failed: %s", response.Status) + } + io.WriteString(c, "hello\n") + line, err := reader.ReadString('\n') + if err != nil || line != "hello\n" { + t.Fatalf("upgraded echo %q %v", line, err) + } +} diff --git a/internal/webview/rpcprovider/README.md b/internal/webview/rpcprovider/README.md index 3590f557..5036ddfc 100644 --- a/internal/webview/rpcprovider/README.md +++ b/internal/webview/rpcprovider/README.md @@ -1,5 +1,11 @@ # rpcprovider +The private Flutter native profile bridge is maintained in the +[GopeedLab flutter_inappwebview fork](https://github.com/GopeedLab/flutter_inappwebview/tree/codex/gopeed-profiles). +All three platform overrides pin one commit in `ui/flutter/pubspec.yaml`; +see the fork’s [GOPEED.md](https://github.com/GopeedLab/flutter_inappwebview/blob/codex/gopeed-profiles/GOPEED.md) +for patch scope and upstream synchronization. + `rpcprovider` is the RPC-backed implementation of Gopeed's `webview.Provider` interface. It is intended for environments where WebView capability is owned by another process, most notably mobile hosts. This package does not implement a WebView itself. Instead, it translates the `pkg/download/engine/webview` page API into single-request `POST + JSON` calls sent to a local host-side RPC service. diff --git a/internal/webview/rpcprovider/provider.go b/internal/webview/rpcprovider/provider.go index 9a22bea2..607bb5a6 100644 --- a/internal/webview/rpcprovider/provider.go +++ b/internal/webview/rpcprovider/provider.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net" "net/http" @@ -109,9 +110,14 @@ func (p *page) ClearCookies() error { } func (p *page) Close() error { - return p.client.Call(enginewebview.MethodPageClose, enginewebview.PageCloseParams{ + err := p.client.Call(enginewebview.MethodPageClose, enginewebview.PageCloseParams{ PageID: p.id, }, nil) + var rpcErr *enginewebview.RPCError + if errors.As(err, &rpcErr) && rpcErr.Code == enginewebview.ErrorCodePageNotFound { + return nil + } + return err } type Client struct { @@ -193,3 +199,7 @@ func (c *Client) Call(method string, params any, result any) error { } return json.Unmarshal(body.Result, result) } + +func (p *Provider) RemoveProfile(profileID, dataPath string) error { + return p.client.Call(enginewebview.MethodProfileRemove, enginewebview.ProfileRemoveParams{ProfileID: profileID}, nil) +} diff --git a/internal/webview/rpcprovider/provider_integration_test.go b/internal/webview/rpcprovider/provider_integration_test.go index 6ade9458..5f26ca56 100644 --- a/internal/webview/rpcprovider/provider_integration_test.go +++ b/internal/webview/rpcprovider/provider_integration_test.go @@ -37,3 +37,8 @@ func defaultWebViewRPCAddress() string { } return filepath.Join(homeDir, "Library", "Application Support", "com.gopeed.gopeed", "gopeed_webview.sock") } + +func TestProviderProfiles(t *testing.T) { + provider := New(enginewebview.RPCConfig{Network: *webViewRPCNetwork, Address: *webViewRPCAddress, Token: *webViewRPCToken}) + integrationtest.RunProfileContract(t, provider) +} diff --git a/internal/webview/rpcprovider/provider_test.go b/internal/webview/rpcprovider/provider_test.go index 9ec03ac1..0a28cd9f 100644 --- a/internal/webview/rpcprovider/provider_test.go +++ b/internal/webview/rpcprovider/provider_test.go @@ -184,3 +184,29 @@ func TestPageSetCookieParamsOmitsZeroExpires(t *testing.T) { t.Fatalf("expected expires in payload: %s", got) } } + +func TestRemoveProfileUsesPrivateHostRPC(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Method string `json:"method"` + Params map[string]string `json:"params"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + } + if request.Method != enginewebview.MethodProfileRemove || request.Params["profileId"] != "extension-profile" || len(request.Params) != 1 { + t.Errorf("unexpected profile removal request: %+v", request) + } + if r.Header.Get("Authorization") != "Bearer secret" { + t.Error("missing host authentication") + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"error":{"code":"PROFILE_REMOVE_FAILED","message":"store busy"}}`)) + })) + defer server.Close() + provider := New(enginewebview.RPCConfig{Network: "tcp", Address: strings.TrimPrefix(server.URL, "http://"), Token: "secret"}) + err := provider.(enginewebview.ProfileRemover).RemoveProfile("extension-profile", "/host-only/path") + if err == nil || err.Error() != "store busy" { + t.Fatalf("cleanup failure was lost: %v", err) + } +} diff --git a/pkg/api/events.go b/pkg/api/events.go index fa2e416f..ac973440 100644 --- a/pkg/api/events.go +++ b/pkg/api/events.go @@ -10,8 +10,15 @@ import ( type TaskEventMask uint64 const ( + // Keep these first two unchanged because Flutter already uses + // bit 0 for done and bit 1 for error. TaskEventDone TaskEventMask = 1 << iota TaskEventError + + TaskEventStart + TaskEventProgress + TaskEventPause + TaskEventDelete ) type TaskEvent struct { @@ -42,11 +49,26 @@ func (s *Service) emitTaskEvent(event *download.Event) { } var mask TaskEventMask + switch event.Key { case download.EventKeyDone: mask = TaskEventDone + case download.EventKeyError: mask = TaskEventError + + case download.EventKeyStart: + mask = TaskEventStart + + case download.EventKeyProgress: + mask = TaskEventProgress + + case download.EventKeyPause: + mask = TaskEventPause + + case download.EventKeyDelete: + mask = TaskEventDelete + default: return } diff --git a/pkg/base/model.go b/pkg/base/model.go index 46bcace2..7b49b297 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -94,9 +94,6 @@ type Resource struct { } func (r *Resource) Validate() error { - if r.Name == "" { - return fmt.Errorf("invalid resource name") - } if len(r.Files) == 0 { return fmt.Errorf("invalid resource files") } diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index 4d98bdb6..4207fd29 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -21,6 +21,7 @@ import ( "github.com/GopeedLab/gopeed/internal/controller" "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/internal/logger" + webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/protocol/http" "github.com/GopeedLab/gopeed/pkg/util" @@ -97,8 +98,12 @@ type Progress struct { } type Downloader struct { - Logger *logger.Logger - ExtensionLogger *logger.Logger + webviewProfilesLock sync.Mutex + webviewProfiles map[string]*extensionWebViewProfile + webviewProxyLock sync.Mutex + webviewProxy *webviewproxy.Server + Logger *logger.Logger + ExtensionLogger *logger.Logger cfg *DownloaderConfig fetcherCache map[string]fetcher.Fetcher @@ -1138,6 +1143,11 @@ func (d *Downloader) doDelete(task *Task, force bool) (err error) { func (d *Downloader) Close() error { d.closed.Store(true) + d.webviewProxyLock.Lock() + if d.webviewProxy != nil { + _ = d.webviewProxy.Close() + } + d.webviewProxyLock.Unlock() closeArr := []func() error{ d.pauseAll, diff --git a/pkg/download/engine/engine.go b/pkg/download/engine/engine.go index fb93662a..147f1f98 100644 --- a/pkg/download/engine/engine.go +++ b/pkg/download/engine/engine.go @@ -9,6 +9,7 @@ import ( "github.com/GopeedLab/gopeed/pkg/base" gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error" fetchapi "github.com/GopeedLab/gopeed/pkg/download/engine/inject/fetch" + ffmpegapi "github.com/GopeedLab/gopeed/pkg/download/engine/inject/ffmpeg" "github.com/GopeedLab/gopeed/pkg/download/engine/inject/file" "github.com/GopeedLab/gopeed/pkg/download/engine/inject/stream" "github.com/GopeedLab/gopeed/pkg/download/engine/inject/vm" @@ -159,8 +160,10 @@ func (e *Engine) addCleanup(cleanup func()) { } type Config struct { - ProxyConfig *base.DownloaderProxyConfig - StreamConfig *stream.Config + // HTTPUserAgent is a snapshot of the downloader HTTP default. Nil leaves standalone engines unchanged. + HTTPUserAgent *string + ProxyConfig *base.DownloaderProxyConfig + StreamConfig *stream.Config } func NewEngine(cfg *Config) *Engine { @@ -203,9 +206,17 @@ func NewEngine(cfg *Config) *Engine { if err := stream.Enable(runtime, loop, cfg.StreamConfig); err != nil { return } + if err := ffmpegapi.Enable(runtime, loop, &ffmpegapi.Config{ + ProxyHandler: cfg.ProxyConfig.ToHandler(), + DefaultUserAgent: cfg.HTTPUserAgent, + RegisterCleanup: engine.addCleanup, + }); err != nil { + return + } if err := fetchapi.Enable(runtime, loop, &fetchapi.Config{ - ProxyHandler: cfg.ProxyConfig.ToHandler(), - RegisterCleanup: engine.addCleanup, + ProxyHandler: cfg.ProxyConfig.ToHandler(), + DefaultUserAgent: cfg.HTTPUserAgent, + RegisterCleanup: engine.addCleanup, }); err != nil { return } diff --git a/pkg/download/engine/ffmpeg/README.md b/pkg/download/engine/ffmpeg/README.md new file mode 100644 index 00000000..c7ef41bd --- /dev/null +++ b/pkg/download/engine/ffmpeg/README.md @@ -0,0 +1,128 @@ +# Extension FFmpeg runtime + +`gopeed.runtime.ffmpeg.merge(options)` returns a `ReadableStream`. +It merges one video track and one audio track without re-encoding, using +[go-ffmpreg](https://codeberg.org/gruf/go-ffmpreg)'s embedded FFmpeg 5.1.10 and +wazero. No system FFmpeg installation is required. go-ffmpreg and its embedded +GPL-enabled FFmpeg are distributed under GPLv3; see the pinned dependency's +LICENSE, README and build scripts for its source and included components. + +## HTTP inputs and one downloadable output + +```js +gopeed.events.onResolve(async (ctx) => { + // Resolve these URLs and headers using the site's API in your extension. + const video = { url: videoURL, headers: { Referer: pageURL } }; + const audio = { url: audioURL, headers: { Referer: pageURL } }; + const url = await gopeed.runtime.blob.createObjectURL( + () => gopeed.runtime.ffmpeg.merge({ video, audio }), + { contentType: "video/mp4" } + ); + ctx.res = { + name: "video", + files: [{ name: "video.mp4", req: { url } }] + }; +}); +``` + +The opener creates a new merge on each open. Output size is unknown and range +support must remain disabled. Do not declare the sum of the input sizes as the +output size. The downloader reads the merged output as one file; FFmpeg errors +propagate through the stream instead of completing a partial download. + +## JS inputs, including SABR + +Stream inputs **must** be created by an `inputs({ signal })` callback. It returns +`{ video, audio }`, each an HTTP descriptor or `ReadableStream`, +including mixed inputs. Do not start producers before this callback. SABR/UMP decoding belongs +to the extension: pass the resulting media streams, with initialization data, +not the SABR protocol bytes. Keep each selected track's codec/configuration +stable for the duration of a merge. + +```js +const output = gopeed.runtime.ffmpeg.merge({ + inputs: async ({ signal }) => { + const session = await prepareSession({ signal }); + const { videoStream, audioStream, abort } = await session.openStreams(); + if (signal.aborted) { abort(); throw new Error("Cancelled"); } + signal.addEventListener("abort", abort, { once: true }); + return { video: videoStream, audio: audioStream }; + }, + signal: abortController.signal, + args: ["-shortest", "-metadata", "title=Example"] +}); +``` + +Creating the output does not start requests or acquire input readers until the +output is pulled and a global execution slot is acquired. Queued jobs do not +open HTTP inputs, invoke input factories, or allocate media ring buffers. +The callback runs once per output; its signal aborts on cancellation, engine +shutdown, failure, and completion. A callback must cooperate with cancellation; +returned streams are cancelled even if the callback finishes after cancellation. +A stream input is single-use. To retry a Blob-backed task, +its opener must create a new SABR session and fresh input streams. Arbitrary +output byte ranges / resumable merged output are not supported. + +`gopeed.runtime.ffmpeg.supportsInputFactory === true` identifies this API. +Older builds accept eager streams and must be upgraded before using callbacks. + +## Options + +- `inputs`: callback returning `{ video, audio }`, synchronously or asynchronously. + Required for stream inputs. Cannot be combined with top-level `video`/`audio`. +- `video`, `audio`: HTTP descriptors only when supplied at the top level; both + are required unless `inputs` is supplied. HTTP descriptors have `url` and optional + `headers` (standard `HeadersInit`). HTTP requests use Gopeed's configured + proxy. Supply required cookies/authorization explicitly; browser or `fetch` + cookie jars are not implicitly shared. When `User-Agent` is absent, requests + use the downloader HTTP protocol's configured UA, captured when the extension + engine is created, matching fetch and XHR. Explicit UA values (including an + empty string) take precedence. +- `format`: defaults to `"mp4"`. MP4 output always enables fragmentation + (`frag_keyframe+empty_moov+default_base_moof`) for non-seekable output. The + other accepted muxers are `"mkv"`/`"matroska"`, `"webm"`, and `"mpegts"`. + Codec/container incompatibility is an error, not an automatic conversion. + Choose the matching filename and Blob content type in the extension. +- `args`: optional array of **output options**, not a shell command or a full + FFmpeg invocation. Defaults select `0:v:0`, `1:a:0`, and `-c copy`. Additional + `-map` options are additive. Allowed options are listed in `outputOptions` in + `runtime.go`: metadata, dispositions, bitstream filters, duration/shortest, + fragmentation, timestamp/interleaving options and `-strict`. Codec options + must remain `copy`. Extra inputs, output paths, reports and arbitrary global + options are rejected. Required MP4 fragmentation is enforced by the host. +- `signal`: optional `AbortSignal`. Aborting or cancelling the output stops + network requests and native execution, cancels input readers, and removes + temporary buffers. Await/consume the output inside a live engine or retain + it through a Blob URL; unowned work ends with the extension engine. + +The backend defaults to strict input errors (`-xerror`). Native errors include +the FFmpeg exit code and a bounded stderr tail. No host directories are exposed +to the WASM module; it can access only its two input files and standard output. + +## Input behavior and resource limits + +HTTP inputs start with a 1 MiB Range GET, reusing the bytes for media reads. +A validated 206 response with a known total size enables seeking; a 200 +response is consumed as-is, sequentially. Subsequent ranges verify positions, +lengths, total size and available ETag/Last-Modified validators. Without a +server validator, same-size remote changes cannot be reliably detected. +Content encoding is forced to identity. Authentication/status failures and +malformed range responses are errors. There is no full-file seek fallback. + +Sequential JS inputs are drained independently into memory ring buffers that +grow on demand up to **32 MiB per input**, independent of total media size. +No temporary files are used. This does not enable seeking. Full buffers apply +async backpressure until space is available or the operation is cancelled. +Extensions must propagate backpressure through their upstream queues and +network reads. Independent upstream track requests avoid blocking one track +behind another in a shared response. Buffers are released on close. +Blob output writes also wait asynchronously for bounded downstream capacity, +so a slow consumer does not block the extension event loop. + +At most two WASM merges execute concurrently per process, with a 512 MiB +linear-memory limit per instance. Compilation is shared and lazy; the upstream +embedded package still decompresses its WASM at process initialization. Native +compilation memory, host buffers and JS allocations are additional to the +linear-memory limit. Playback support depends on the selected codecs, not just +the `.mp4` suffix. Platform execution and performance must be tested on target +devices, especially mobile/32-bit devices. diff --git a/pkg/download/engine/ffmpeg/input.go b/pkg/download/engine/ffmpeg/input.go new file mode 100644 index 00000000..74e9c453 --- /dev/null +++ b/pkg/download/engine/ffmpeg/input.go @@ -0,0 +1,227 @@ +// Package ffmpeg runs an embedded FFmpeg with host-controlled media inputs. +package ffmpeg + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +const httpBlockSize = 1024 * 1024 + +// Input is a file visible to FFmpeg. Only verified HTTP Range sources seek. +type Input interface { + io.ReadCloser + Seek(int64, int) (int64, error) + Size() int64 + Seekable() bool +} + +var ErrSeek = errors.New("ffmpeg: input requires seeking but source is sequential") + +type HTTPSource struct { + URL string `json:"url"` + Headers map[string]string `json:"headers"` +} + +type httpInput struct { + ctx context.Context + client *http.Client + source HTTPSource + body io.ReadCloser + size, pos, blockStart int64 + block []byte + ranged bool + etag, modified string +} + +// OpenHTTP combines capability detection with the first media read. A 200 +// response remains open and is consumed sequentially without another request. +func OpenHTTP(ctx context.Context, client *http.Client, source HTTPSource) (Input, error) { + u, err := url.Parse(source.URL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, errors.New("ffmpeg: expected an HTTP(S) input URL") + } + h := &httpInput{ctx: ctx, client: client, source: source, size: -1} + resp, err := h.request(0, httpBlockSize-1) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusOK { + if err = identityEncoding(resp); err != nil { + resp.Body.Close() + return nil, err + } + h.body, h.size = resp.Body, resp.ContentLength + return h, nil + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusPartialContent { + return nil, fmt.Errorf("ffmpeg: HTTP input returned %d", resp.StatusCode) + } + start, end, size, err := contentRange(resp.Header.Get("Content-Range")) + if err != nil || start != 0 || end >= httpBlockSize { + return nil, errors.New("ffmpeg: invalid initial Content-Range") + } + h.size, h.ranged = size, true + h.etag = resp.Header.Get("ETag") + if strings.HasPrefix(h.etag, "W/") { + h.etag = "" + } + h.modified = resp.Header.Get("Last-Modified") + h.block, err = readBlock(resp, end-start+1) + return h, err +} + +func (h *httpInput) request(start, end int64) (*http.Response, error) { + req, err := http.NewRequestWithContext(h.ctx, http.MethodGet, h.source.URL, nil) + if err != nil { + return nil, err + } + for k, v := range h.source.Headers { + req.Header.Set(k, v) + } + // Offsets must refer to the actual media bytes, not a decompressed response. + req.Header.Set("Accept-Encoding", "identity") + req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end)) + if h.etag != "" { + req.Header.Set("If-Range", h.etag) + } else if h.modified != "" { + req.Header.Set("If-Range", h.modified) + } + return h.client.Do(req) +} + +func identityEncoding(resp *http.Response) error { + if enc := resp.Header.Get("Content-Encoding"); enc != "" && !strings.EqualFold(enc, "identity") { + return errors.New("ffmpeg: encoded HTTP responses cannot represent media byte offsets") + } + return nil +} + +func contentRange(value string) (start, end, size int64, err error) { + err = errors.New("invalid Content-Range") + if !strings.HasPrefix(value, "bytes ") { + return + } + parts := strings.Split(strings.TrimPrefix(value, "bytes "), "/") + if len(parts) != 2 { + return + } + bounds := strings.Split(parts[0], "-") + if len(bounds) != 2 { + return + } + var e error + if start, e = strconv.ParseInt(bounds[0], 10, 64); e != nil { + return + } + if end, e = strconv.ParseInt(bounds[1], 10, 64); e != nil { + return + } + if size, e = strconv.ParseInt(parts[1], 10, 64); e != nil { + return + } + if start < 0 || end < start || size <= end { + return + } + err = nil + return +} + +func readBlock(resp *http.Response, length int64) ([]byte, error) { + if err := identityEncoding(resp); err != nil { + return nil, err + } + if resp.ContentLength >= 0 && resp.ContentLength != length { + return nil, errors.New("ffmpeg: Range response length mismatch") + } + b, err := io.ReadAll(io.LimitReader(resp.Body, length+1)) + if err != nil { + return nil, err + } + if int64(len(b)) != length { + return nil, errors.New("ffmpeg: truncated or oversized Range response") + } + return b, nil +} + +func (h *httpInput) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + if err := h.ctx.Err(); err != nil { + return 0, err + } + if !h.ranged { + return h.body.Read(p) + } + if h.pos >= h.size { + return 0, io.EOF + } + if h.pos < h.blockStart || h.pos >= h.blockStart+int64(len(h.block)) { + end := min(h.pos+httpBlockSize-1, h.size-1) + resp, err := h.request(h.pos, end) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusPartialContent { + return 0, fmt.Errorf("ffmpeg: Range request returned %d; source changed or stopped supporting Range", resp.StatusCode) + } + start, actualEnd, size, err := contentRange(resp.Header.Get("Content-Range")) + if err != nil || start != h.pos || actualEnd > end || size != h.size { + return 0, errors.New("ffmpeg: inconsistent Content-Range") + } + if h.etag != "" && resp.Header.Get("ETag") != h.etag { + return 0, errors.New("ffmpeg: HTTP input ETag changed") + } + if h.etag == "" && h.modified != "" && resp.Header.Get("Last-Modified") != h.modified { + return 0, errors.New("ffmpeg: HTTP input Last-Modified changed") + } + h.block, err = readBlock(resp, actualEnd-start+1) + if err != nil { + return 0, err + } + h.blockStart = start + } + n := copy(p, h.block[h.pos-h.blockStart:]) + h.pos += int64(n) + return n, nil +} + +func (h *httpInput) Seek(offset int64, whence int) (int64, error) { + if !h.ranged { + return 0, ErrSeek + } + base := int64(0) + switch whence { + case io.SeekStart: + case io.SeekCurrent: + base = h.pos + case io.SeekEnd: + base = h.size + default: + return 0, errors.New("invalid seek origin") + } + next := base + offset + if next < 0 || (offset > 0 && next < base) { + return 0, errors.New("invalid seek offset") + } + h.pos = next + return next, nil +} + +func (h *httpInput) Size() int64 { return h.size } +func (h *httpInput) Seekable() bool { return h.ranged } +func (h *httpInput) Close() error { + if h.body != nil { + return h.body.Close() + } + return nil +} diff --git a/pkg/download/engine/ffmpeg/input_test.go b/pkg/download/engine/ffmpeg/input_test.go new file mode 100644 index 00000000..63e86c21 --- /dev/null +++ b/pkg/download/engine/ffmpeg/input_test.go @@ -0,0 +1,222 @@ +package ffmpeg + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestHTTPRange(t *testing.T) { + data := bytes.Repeat([]byte("0123456789"), 300000) + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := requests.Add(1) + if r.Header.Get("X-Test") != "secret" { + t.Error("missing headers") + } + if r.Header.Get("Accept-Encoding") != "identity" { + t.Error("missing identity encoding") + } + if count > 1 && r.Header.Get("If-Range") != `"v1"` { + t.Error("missing If-Range") + } + w.Header().Set("ETag", `"v1"`) + http.ServeContent(w, r, "media", time.Time{}, bytes.NewReader(data)) + })) + defer srv.Close() + in, err := OpenHTTP(context.Background(), srv.Client(), HTTPSource{URL: srv.URL, Headers: map[string]string{"X-Test": "secret"}}) + if err != nil { + t.Fatal(err) + } + defer in.Close() + if !in.Seekable() || in.Size() != int64(len(data)) { + t.Fatal("range not detected") + } + for _, off := range []int64{0, int64(len(data) - 16), 12, int64(httpBlockSize + 35)} { + if _, err = in.Seek(off, io.SeekStart); err != nil { + t.Fatal(err) + } + b := make([]byte, 16) + if _, err = io.ReadFull(in, b); err != nil { + t.Fatal(err) + } + if !bytes.Equal(b, data[off:off+16]) { + t.Fatalf("wrong bytes at %d", off) + } + } +} + +func TestHTTPSequentialAndValidation(t *testing.T) { + for _, kind := range []string{"sequential", "bad-range", "changed", "truncated", "unauthorized"} { + t.Run(kind, func(t *testing.T) { + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := requests.Add(1) + switch kind { + case "sequential": + w.Write([]byte("media")) + case "unauthorized": + w.WriteHeader(403) + case "bad-range": + w.Header().Set("Content-Range", "bytes 2-6/10") + w.WriteHeader(206) + w.Write([]byte("media")) + case "truncated": + w.Header().Set("Content-Range", "bytes 0-9/10") + w.WriteHeader(206) + w.Write([]byte("media")) + case "changed": + if count == 1 { + w.Header().Set("Content-Range", "bytes 0-4/10") + w.Header().Set("ETag", `"v1"`) + w.WriteHeader(206) + } + w.Write([]byte("media")) + } + })) + defer srv.Close() + in, err := OpenHTTP(context.Background(), srv.Client(), HTTPSource{URL: srv.URL}) + if kind == "sequential" { + if err != nil { + t.Fatal(err) + } + defer in.Close() + if in.Seekable() { + t.Fatal("unexpected seeking") + } + if _, err = in.Seek(0, 0); err != ErrSeek { + t.Fatal(err) + } + b, err := io.ReadAll(in) + if err != nil || string(b) != "media" || requests.Load() != 1 { + t.Fatalf("%q %v count %d", b, err, requests.Load()) + } + } else if kind == "changed" { + if err != nil { + t.Fatal(err) + } + defer in.Close() + in.Seek(5, 0) + if _, err = in.Read(make([]byte, 2)); err == nil { + t.Fatal("accepted 200 after range") + } + } else if err == nil { + in.Close() + t.Fatal("accepted invalid response") + } + }) + } +} + +func TestStreamRing(t *testing.T) { + s := NewStreamInput(context.Background()) + defer s.Close() + // Start near the boundary to exercise wrap-around without a huge fixture. + s.buffer = make([]byte, 256*1024) + s.read = int64(len(s.buffer)) - 3 + s.written = s.read + if _, err := s.Write([]byte("abcdefgh")); err != nil { + t.Fatal(err) + } + s.End(nil) + b, err := io.ReadAll(s) + if err != nil || string(b) != "abcdefgh" { + t.Fatalf("%q %v", b, err) + } + if err = s.Close(); err != nil { + t.Fatal(err) + } + if s.buffer != nil { + t.Fatal("buffer retained after close") + } +} + +func TestOutputArguments(t *testing.T) { + a, err := Arguments("", []string{"-shortest", "-metadata", "title=test"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(a, " "), "-f mp4 pipe:1") { + t.Fatal(a) + } + for _, args := range [][]string{{"-i", "/etc/passwd"}, {"-report"}, {"-c:v", "libx264"}, {"-metadata"}, {"other.mp4"}} { + if _, err := Arguments("mp4", args); err == nil { + t.Fatal(fmt.Sprint(args)) + } + } +} + +func TestStreamBackpressureAndCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s := NewStreamInput(ctx) + defer s.Close() + s.buffer = make([]byte, StreamBufferLimit) + s.written = StreamBufferLimit + finished := make(chan error, 1) + go func() { _, err := s.Write([]byte{42}); finished <- err }() + select { + case err := <-finished: + t.Fatalf("did not apply backpressure: %v", err) + case <-time.After(10 * time.Millisecond): + } + if _, err := s.Read(make([]byte, 1)); err != nil { + t.Fatal(err) + } + select { + case err := <-finished: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("writer did not resume") + } + go func() { _, err := s.Write([]byte{43}); finished <- err }() + cancel() + select { + case err := <-finished: + if err == nil { + t.Fatal("cancelled write succeeded") + } + case <-time.After(time.Second): + t.Fatal("writer did not cancel") + } +} + +func TestStreamMemoryGrowthAndWrap(t *testing.T) { + s := NewStreamInput(context.Background()) + defer s.Close() + if len(s.buffer) != 0 { + t.Fatal("buffer allocated before first write") + } + a := bytes.Repeat([]byte{1}, 200*1024) + b := bytes.Repeat([]byte{2}, 180*1024) + s.Write(a) + consumed := make([]byte, 180*1024) + if _, err := io.ReadFull(s, consumed); err != nil { + t.Fatal(err) + } + s.Write(b) // Wrap in the initial 256 KiB allocation. + c := bytes.Repeat([]byte{3}, 300*1024) + s.Write(c) // Grow while unread data wraps around the old ring. + s.End(nil) + got, err := io.ReadAll(s) + want := append(append(append([]byte{}, a[len(consumed):]...), b...), c...) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("growth corrupted data: %v", err) + } + if len(s.buffer) > StreamBufferLimit { + t.Fatal("buffer exceeded limit") + } + s.Close() + if s.buffer != nil { + t.Fatal("buffer retained on close") + } +} diff --git a/pkg/download/engine/ffmpeg/runtime.go b/pkg/download/engine/ffmpeg/runtime.go new file mode 100644 index 00000000..2d6b192f --- /dev/null +++ b/pkg/download/engine/ffmpeg/runtime.go @@ -0,0 +1,254 @@ +package ffmpeg + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "strings" + "sync" + + ffembed "codeberg.org/gruf/go-ffmpreg/embed" + "codeberg.org/gruf/go-ffmpreg/wasm" + "github.com/tetratelabs/wazero" + expsys "github.com/tetratelabs/wazero/experimental/sys" + "github.com/tetratelabs/wazero/experimental/sysfs" + "github.com/tetratelabs/wazero/sys" +) + +var shared struct { + once sync.Once + runtime wazero.Runtime + module wazero.CompiledModule + err error +} + +// Limit active instances independently of the number of extension engines. +var slots = make(chan struct{}, 2) + +func initialize() { + shared.once.Do(func() { + defer func() { + if r := recover(); r != nil { + shared.err = fmt.Errorf("ffmpeg initialization: %v", r) + } + }() + cfg := wazero.NewRuntimeConfig().WithCloseOnContextDone(true).WithMemoryLimitPages(8192) + shared.runtime, shared.err = wasm.NewRuntime(context.Background(), cfg) + if shared.err != nil { + return + } + shared.module, shared.err = shared.runtime.CompileModule(context.Background(), ffembed.B()) + if shared.err != nil { + shared.runtime.Close(context.Background()) + return + } + ffembed.Free() + }) +} + +// Output arguments deliberately do not accept extra inputs, output paths or +// global filesystem/logging options. The host owns all I/O and cancellation. +var outputOptions = map[string]int{ + "-c": 1, "-codec": 1, "-c:v": 1, "-c:a": 1, "-codec:v": 1, "-codec:a": 1, + "-map": 1, "-map_metadata": 1, "-map_chapters": 1, "-metadata": 1, "-metadata:s:a:0": 1, "-metadata:s:v:0": 1, + "-movflags": 1, "-frag_duration": 1, "-frag_size": 1, "-min_frag_duration": 1, + "-bsf:v": 1, "-bsf:a": 1, "-strict": 1, "-shortest": 0, "-t": 1, "-to": 1, + "-avoid_negative_ts": 1, "-max_interleave_delta": 1, "-flush_packets": 1, + "-disposition:a:0": 1, "-disposition:v:0": 1, +} + +func Arguments(format string, extra []string) ([]string, error) { + if format == "" { + format = "mp4" + } + if format == "mkv" { + format = "matroska" + } + switch format { + case "mp4", "matroska", "webm", "mpegts": + default: + return nil, fmt.Errorf("ffmpeg: unsupported output format %q", format) + } + for i := 0; i < len(extra); { + count, ok := outputOptions[extra[i]] + if !ok { + return nil, fmt.Errorf("ffmpeg: unsupported output option %q", extra[i]) + } + if i+count >= len(extra) { + return nil, fmt.Errorf("ffmpeg: missing value for %s", extra[i]) + } + if strings.HasPrefix(extra[i], "-c") && extra[i+1] != "copy" { + return nil, errors.New("ffmpeg: merge only supports stream copy") + } + i += count + 1 + } + args := []string{"-hide_banner", "-nostdin", "-xerror", "-loglevel", "error", "-protocol_whitelist", "file,pipe", "-i", "/inputs/video", "-protocol_whitelist", "file,pipe", "-i", "/inputs/audio", "-map", "0:v:0", "-map", "1:a:0", "-c", "copy"} + fragmentFlags := "" + for i := 0; i < len(extra); { + count := outputOptions[extra[i]] + if format == "mp4" && extra[i] == "-movflags" { + if strings.Contains(extra[i+1], "faststart") || strings.Contains(extra[i+1], "global_sidx") { + return nil, errors.New("ffmpeg: these movflags require seekable output") + } + fragmentFlags = extra[i+1] + } else { + args = append(args, extra[i:i+count+1]...) + } + i += count + 1 + } + if format == "mp4" { + args = append(args, "-movflags", fragmentFlags+"+frag_keyframe+empty_moov+default_base_moof") + } + args = append(args, "-f", format, "pipe:1") + return args, nil +} + +// Run shares compilation, but gives every merge its own memory and filesystem. +// A clean stdout EOF is only delivered by the caller after this succeeds. +func Run(ctx context.Context, video, audio Input, out io.Writer, format string, extra []string) error { + _, err := Arguments(format, extra) + if err != nil { + return err + } + release, err := Acquire(ctx) + if err != nil { + return err + } + defer release() + return RunAcquired(ctx, video, audio, out, format, extra) +} + +// Acquire reserves execution capacity before input factories or HTTP requests start. +func Acquire(ctx context.Context) (func(), error) { + select { + case slots <- struct{}{}: + case <-ctx.Done(): + return nil, ctx.Err() + } + var once sync.Once + return func() { once.Do(func() { <-slots }) }, nil +} + +// RunAcquired requires a reservation held until this call returns. +func RunAcquired(ctx context.Context, video, audio Input, out io.Writer, format string, extra []string) error { + args, err := Arguments(format, extra) + if err != nil { + return err + } + ready := make(chan struct{}) + go func() { initialize(); close(ready) }() + select { + case <-ready: + case <-ctx.Done(): + return ctx.Err() + } + if shared.err != nil { + return shared.err + } + files := &inputFS{files: map[string]Input{"video": video, "audio": audio}} + fsconfig := wazero.NewFSConfig().(sysfs.FSConfig).WithSysFSMount(files, "/inputs") + logs := &tailLog{} + rc, err := wasm.Run(ctx, shared.runtime, shared.module, wasm.Args{ + Name: "ffmpeg", Args: args, Stdout: out, Stderr: logs, + Config: func(c wazero.ModuleConfig) wazero.ModuleConfig { return c.WithFSConfig(fsconfig) }, + }) + if ctx.Err() != nil { + return ctx.Err() + } + if files.err != nil { + return fmt.Errorf("ffmpeg input: %w (%s)", files.err, logs.String()) + } + if err != nil { + return fmt.Errorf("ffmpeg runtime: %w (%s)", err, logs.String()) + } + if rc != 0 { + return fmt.Errorf("ffmpeg exited with code %d: %s", rc, logs.String()) + } + return nil +} + +type tailLog struct{ data []byte } + +func (l *tailLog) Write(p []byte) (int, error) { + n := len(p) + if len(p) > 8192 { + p = p[len(p)-8192:] + } + l.data = append(l.data, p...) + if len(l.data) > 8192 { + l.data = l.data[len(l.data)-8192:] + } + return n, nil +} +func (l *tailLog) String() string { return strings.TrimSpace(string(l.data)) } + +type inputFS struct { + expsys.UnimplementedFS + files map[string]Input + err error +} + +func (f *inputFS) OpenFile(path string, flag expsys.Oflag, perm fs.FileMode) (expsys.File, expsys.Errno) { + if flag&(expsys.O_WRONLY|expsys.O_RDWR|expsys.O_CREAT|expsys.O_TRUNC) != 0 { + return nil, expsys.EROFS + } + path = strings.TrimPrefix(path, "/") + if path == "." || path == "" { + return &inputFile{owner: f}, 0 + } + in := f.files[path] + if in == nil { + return nil, expsys.ENOENT + } + return &inputFile{owner: f, in: in}, 0 +} +func (f *inputFS) Stat(path string) (sys.Stat_t, expsys.Errno) { + file, err := f.OpenFile(path, 0, 0) + if err != 0 { + return sys.Stat_t{}, err + } + return file.Stat() +} + +type inputFile struct { + expsys.UnimplementedFile + owner *inputFS + in Input +} + +func (f *inputFile) IsDir() (bool, expsys.Errno) { return f.in == nil, 0 } +func (f *inputFile) Stat() (sys.Stat_t, expsys.Errno) { + if f.in == nil { + return sys.Stat_t{Mode: fs.ModeDir | 0555}, 0 + } + mode := fs.FileMode(0444) + if !f.in.Seekable() { + mode |= fs.ModeNamedPipe + } + return sys.Stat_t{Mode: mode, Size: max(0, f.in.Size()), Nlink: 1}, 0 +} +func (f *inputFile) Read(p []byte) (int, expsys.Errno) { + if f.in == nil { + return 0, expsys.EISDIR + } + n, err := f.in.Read(p) + if err != nil && err != io.EOF { + f.owner.err = err + return n, expsys.EIO + } + return n, 0 +} +func (f *inputFile) Seek(off int64, whence int) (int64, expsys.Errno) { + if f.in == nil { + return 0, expsys.EISDIR + } + n, err := f.in.Seek(off, whence) + // Capability probes may legitimately fail; a failed seek alone is not a + // media error. FFmpeg decides whether it can continue sequentially. + if err != nil { + return 0, expsys.ENOSYS + } + return n, 0 +} diff --git a/pkg/download/engine/ffmpeg/runtime_test.go b/pkg/download/engine/ffmpeg/runtime_test.go new file mode 100644 index 00000000..ee57c180 --- /dev/null +++ b/pkg/download/engine/ffmpeg/runtime_test.go @@ -0,0 +1,215 @@ +package ffmpeg + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "codeberg.org/gruf/go-ffmpreg/wasm" + "github.com/tetratelabs/wazero" +) + +// Generate tiny deterministic media with the exact embedded build under test; +// no system FFmpeg, network media or third-party fixture licensing is needed. +func fixture(t *testing.T, audio bool) []byte { + t.Helper() + initialize() + if shared.err != nil { + t.Fatal(shared.err) + } + args := []string{"-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=black:s=32x32:r=10", "-t", "0.3", "-c:v", "libx264", "-preset", "ultrafast", "-movflags", "frag_keyframe+empty_moov", "-f", "mp4", "pipe:1"} + if audio { + args = []string{"-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=mono", "-t", "0.3", "-c:a", "aac", "-movflags", "frag_keyframe+empty_moov", "-f", "mp4", "pipe:1"} + } + var out, logs bytes.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + rc, err := wasm.Run(ctx, shared.runtime, shared.module, wasm.Args{Name: "ffmpeg", Args: args, Stdout: &out, Stderr: &logs}) + if err != nil || rc != 0 { + t.Fatalf("fixture: %d %v %s", rc, err, logs.String()) + } + return out.Bytes() +} + +func TestTailMoovNeedsRange(t *testing.T) { + initialize() + if shared.err != nil { + t.Fatal(shared.err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + dir := t.TempDir() + var logs bytes.Buffer + rc, err := wasm.Run(ctx, shared.runtime, shared.module, wasm.Args{Name: "ffmpeg", Args: []string{ + "-v", "error", "-f", "lavfi", "-i", "color=c=black:s=32x32:r=10", "-t", "0.3", "-c:v", "libx264", "-preset", "ultrafast", "/out/video.mp4", + }, Stderr: &logs, Config: func(c wazero.ModuleConfig) wazero.ModuleConfig { + return c.WithFSConfig(wazero.NewFSConfig().WithDirMount(dir, "/out")) + }}) + if rc != 0 || err != nil { + t.Fatalf("fixture %d %v %s", rc, err, logs.String()) + } + raw, err := os.ReadFile(filepath.Join(dir, "video.mp4")) + if err != nil { + t.Fatal(err) + } + var video []byte + for offset := 0; offset < len(raw); { + length := int(binary.BigEndian.Uint32(raw[offset : offset+4])) + if string(raw[offset+4:offset+8]) == "moov" { + free := make([]byte, 2*httpBlockSize) + binary.BigEndian.PutUint32(free, uint32(len(free))) + copy(free[4:], "free") + video = append(video, free...) + } + video = append(video, raw[offset:offset+length]...) + offset += length + } + audio := fixture(t, true) + for _, ranged := range []bool{true, false} { + t.Run(fmtBool(ranged), func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data := video + if r.URL.Path == "/audio" { + data = audio + } + if ranged { + http.ServeContent(w, r, "media", time.Time{}, bytes.NewReader(data)) + } else { + w.Write(data) + } + })) + defer srv.Close() + v, err := OpenHTTP(ctx, srv.Client(), HTTPSource{URL: srv.URL}) + if err != nil { + t.Fatal(err) + } + defer v.Close() + a, err := OpenHTTP(ctx, srv.Client(), HTTPSource{URL: srv.URL + "/audio"}) + if err != nil { + t.Fatal(err) + } + defer a.Close() + err = Run(ctx, v, a, io.Discard, "mp4", nil) + if ranged && err != nil { + t.Fatal(err) + } + if !ranged && err == nil { + t.Fatal("non-seekable tail-moov input unexpectedly succeeded") + } + }) + } +} + +func fmtBool(b bool) string { + if b { + return "range" + } + return "sequential" +} + +func TestWebMToMP4(t *testing.T) { + initialize() + if shared.err != nil { + t.Fatal(shared.err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + inputs := make([]*StreamInput, 2) + for i, name := range []string{"video.webm", "audio.webm"} { + data, err := os.ReadFile(filepath.Join("..", "testdata", "ffmpeg", name)) + if err != nil { + t.Fatal(err) + } + inputs[i] = NewStreamInput(ctx) + defer inputs[i].Close() + if _, err = inputs[i].Write(data); err != nil { + t.Fatal(err) + } + inputs[i].End(nil) + } + + var output, probe, logs bytes.Buffer + if err := Run(ctx, inputs[0], inputs[1], &output, "mp4", nil); err != nil { + t.Fatal(err) + } + rc, err := wasm.Run(ctx, shared.runtime, shared.module, wasm.Args{Name: "ffprobe", Args: []string{"-v", "error", "-show_entries", "stream=codec_name", "-of", "json", "pipe:0"}, Stdin: bytes.NewReader(output.Bytes()), Stdout: &probe, Stderr: &logs}) + if rc != 0 || err != nil { + t.Fatalf("probe %d %v %s", rc, err, logs.String()) + } + if !bytes.Contains(probe.Bytes(), []byte(`"vp9"`)) || !bytes.Contains(probe.Bytes(), []byte(`"opus"`)) { + t.Fatalf("codecs changed: %s", probe.String()) + } +} + +func TestWASMMerge(t *testing.T) { + video, audio := fixture(t, false), fixture(t, true) + for _, ranged := range []bool{false, true} { + t.Run(map[bool]string{false: "sequential", true: "range"}[ranged], func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data := video + if r.URL.Path == "/audio" { + data = audio + } + if ranged { + http.ServeContent(w, r, "media", time.Time{}, bytes.NewReader(data)) + } else { + w.Write(data) + } + })) + defer srv.Close() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + v, err := OpenHTTP(ctx, srv.Client(), HTTPSource{URL: srv.URL + "/video"}) + if err != nil { + t.Fatal(err) + } + defer v.Close() + a, err := OpenHTTP(ctx, srv.Client(), HTTPSource{URL: srv.URL + "/audio"}) + if err != nil { + t.Fatal(err) + } + defer a.Close() + var output bytes.Buffer + if err = Run(ctx, v, a, &output, "", nil); err != nil { + t.Fatal(err) + } + if !bytes.Contains(output.Bytes(), []byte("moof")) { + t.Fatal("output is not fragmented MP4") + } + // Probe the actual result, rather than just checking its filename/header. + var probe, logs bytes.Buffer + rc, err := wasm.Run(ctx, shared.runtime, shared.module, wasm.Args{Name: "ffprobe", Args: []string{"-v", "error", "-show_streams", "-of", "json", "pipe:0"}, Stdin: bytes.NewReader(output.Bytes()), Stdout: &probe, Stderr: &logs}) + if rc != 0 || err != nil { + t.Fatalf("probe: %d %v %s", rc, err, logs.String()) + } + var result struct { + Streams []struct { + CodecType string `json:"codec_type"` + CodecName string `json:"codec_name"` + } + } + if err = json.Unmarshal(probe.Bytes(), &result); err != nil { + t.Fatal(err) + } + if len(result.Streams) != 2 || result.Streams[0].CodecName != "h264" || result.Streams[1].CodecName != "aac" { + t.Fatalf("unexpected streams %s", probe.String()) + } + }) + } + // A malformed input must produce an error, not successful empty output. + s := NewStreamInput(context.Background()) + defer s.Close() + s.Write([]byte("invalid")) + s.End(nil) + if err := Run(context.Background(), s, s, io.Discard, "", nil); err == nil { + t.Fatal("accepted invalid media") + } +} diff --git a/pkg/download/engine/ffmpeg/stream.go b/pkg/download/engine/ffmpeg/stream.go new file mode 100644 index 00000000..7db44f4f --- /dev/null +++ b/pkg/download/engine/ffmpeg/stream.go @@ -0,0 +1,163 @@ +package ffmpeg + +import ( + "context" + "errors" + "io" + "sync" +) + +// A bounded memory ring lets both JS inputs drain independently, including when +// they share a SABR producer. It does not make a sequential input seekable. +// A full ring waits for consumption or cancellation. Producers must propagate +// backpressure and allow each track to make progress independently. +const StreamBufferLimit = 32 * 1024 * 1024 + +type StreamInput struct { + ctx context.Context + mu sync.Mutex + buffer []byte + read, written int64 + ended, closed bool + err error + wake chan struct{} + space chan struct{} +} + +func NewStreamInput(ctx context.Context) *StreamInput { + return &StreamInput{ctx: ctx, wake: make(chan struct{}, 1), space: make(chan struct{}, 1)} +} + +func (s *StreamInput) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + if len(p) > StreamBufferLimit { + return 0, errors.New("ffmpeg: input chunk exceeds buffer capacity") + } + for int64(len(p)) > StreamBufferLimit-(s.written-s.read) && !s.closed && !s.ended { + s.mu.Unlock() + var err error + select { + case <-s.space: + case <-s.ctx.Done(): + err = s.ctx.Err() + } + s.mu.Lock() + if err != nil { + return 0, err + } + } + if s.closed || s.ended { + return 0, io.ErrClosedPipe + } + if err := s.ctx.Err(); err != nil { + return 0, err + } + if len(p) == 0 { + return 0, nil + } + // Grow only as needed. Rebase unread bytes when the ring grows. + need := int(s.written-s.read) + len(p) + if need > len(s.buffer) { + capacity := min(StreamBufferLimit, max(256*1024, max(need, len(s.buffer)*2))) + buffer := make([]byte, capacity) + unread := int(s.written - s.read) + if unread > 0 { + off := int(s.read % int64(len(s.buffer))) + first := min(unread, len(s.buffer)-off) + copy(buffer, s.buffer[off:off+first]) + copy(buffer[first:], s.buffer[:unread-first]) + } + s.buffer = buffer + s.read, s.written = 0, int64(unread) + } + off := int(s.written % int64(len(s.buffer))) + first := min(len(p), len(s.buffer)-off) + copy(s.buffer[off:], p[:first]) + copy(s.buffer, p[first:]) + s.written += int64(len(p)) + select { + case s.wake <- struct{}{}: + default: + } + return len(p), nil +} + +func (s *StreamInput) End(err error) { + s.mu.Lock() + s.ended = true + if s.err == nil { + s.err = err + } + s.mu.Unlock() + select { + case s.space <- struct{}{}: + default: + } + select { + case s.wake <- struct{}{}: + default: + } +} + +func (s *StreamInput) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + for { + if err := s.ctx.Err(); err != nil { + return 0, err + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return 0, io.ErrClosedPipe + } + if s.err != nil { + err := s.err + s.mu.Unlock() + return 0, err + } + if s.read < s.written { + off := s.read % int64(len(s.buffer)) + n := min(int64(len(p)), s.written-s.read, int64(len(s.buffer))-off) + count := copy(p, s.buffer[off:off+n]) + s.read += int64(count) + s.mu.Unlock() + select { + case s.space <- struct{}{}: + default: + } + return count, nil + } + ended := s.ended + s.mu.Unlock() + if ended { + return 0, io.EOF + } + select { + case <-s.wake: + case <-s.ctx.Done(): + return 0, s.ctx.Err() + } + } +} + +func (s *StreamInput) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + s.closed = true + select { + case s.space <- struct{}{}: + default: + } + select { + case s.wake <- struct{}{}: + default: + } + s.buffer = nil + return nil +} +func (*StreamInput) Seek(int64, int) (int64, error) { return 0, ErrSeek } +func (*StreamInput) Size() int64 { return -1 } +func (*StreamInput) Seekable() bool { return false } diff --git a/pkg/download/engine/ffmpeg_integration_test.go b/pkg/download/engine/ffmpeg_integration_test.go new file mode 100644 index 00000000..29c988ce --- /dev/null +++ b/pkg/download/engine/ffmpeg_integration_test.go @@ -0,0 +1,321 @@ +package engine + +import ( + "bytes" + "context" + "fmt" + media "github.com/GopeedLab/gopeed/pkg/download/engine/ffmpeg" + "github.com/GopeedLab/gopeed/pkg/download/engine/inject/stream" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestFFmpegJSStreams(t *testing.T) { + video, err := os.ReadFile("testdata/ffmpeg/video.mp4") + if err != nil { + t.Fatal(err) + } + audio, err := os.ReadFile("testdata/ffmpeg/audio.mp4") + if err != nil { + t.Fatal(err) + } + for _, mode := range []string{"streams", "http", "mixed"} { + t.Run(mode, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b := video + if r.URL.Path == "/audio" { + b = audio + } + http.ServeContent(w, r, "media", time.Time{}, bytes.NewReader(b)) + })) + defer srv.Close() + e := NewEngine(nil) + defer e.Close() + // Input data is deliberately interleaved through one JS producer. + // Native push/read operations must never block the event loop. + e.Runtime.Set("videoBytes", e.Runtime.NewArrayBuffer(video)) + e.Runtime.Set("audioBytes", e.Runtime.NewArrayBuffer(audio)) + e.Runtime.Set("sourceURL", srv.URL) + e.Runtime.Set("inputMode", mode) + result, err := e.RunString(`(async()=>{ + let vc, ac, offset=0; + const v=new Uint8Array(videoBytes), a=new Uint8Array(audioBytes); + const video=new ReadableStream({start(c){vc=c;}}); + const audio=new ReadableStream({start(c){ac=c;}}); + const timer=setInterval(()=>{ + if(offset=Math.max(v.length,a.length)){clearInterval(timer);vc.close();ac.close();} + },1); + const stream=__gopeed_ffmpeg.merge({ + inputs: () => ({ video:inputMode==='http'?{url:sourceURL+'/video'}:video, + audio:inputMode==='streams'?audio:{url:sourceURL+'/audio'} }), + args:['-metadata','title=gopeed-test'] + }); + const bytes=await new Response(stream).arrayBuffer(); + return new Uint8Array(bytes); + })()`) + if err != nil { + t.Fatal(err) + } + out, ok := result.([]byte) + if !ok { + t.Fatalf("output %T", result) + } + if !bytes.Contains(out, []byte("moof")) || !bytes.Contains(out, []byte("gopeed-test")) { + t.Fatalf("invalid output (%d bytes)", len(out)) + } + }) + } +} + +func TestFFmpegJSErrorAndCancel(t *testing.T) { + for _, scenario := range []string{"input-error", "abort", "output-cancel", "invalid-args"} { + t.Run(scenario, func(t *testing.T) { + e := NewEngine(nil) + defer e.Close() + e.Runtime.Set("scenario", scenario) + result, err := e.RunString(`(async()=>{ + let cancelled=0; + const source=()=>new ReadableStream({ + pull(c){if(scenario==='input-error')c.error(new Error('upstream failed'));else return new Promise(()=>{});}, + cancel(){cancelled++;} + }); + const controller=new AbortController(); + const output=__gopeed_ffmpeg.merge({inputs:()=>({video:source(),audio:source()}),signal:controller.signal, + args:scenario==='invalid-args'?['-i','/outside']:[]}); + const reader=output.getReader(); + const pending=reader.read().then(()=>'',e=>e.message); + if(scenario==='abort')setTimeout(()=>controller.abort(),20); + if(scenario==='output-cancel')setTimeout(()=>reader.cancel(),20); + const message=await pending; + await new Promise(r=>setTimeout(r,10)); + return {message,cancelled}; + })()`) + if err != nil { + t.Fatal(err) + } + m := result.(map[string]any) + if scenario != "output-cancel" && m["message"] == "" { + t.Fatalf("missing error: %v", m) + } + if scenario == "abort" || scenario == "output-cancel" { + if fmt.Sprint(m["cancelled"]) != "2" { + t.Fatalf("inputs not cancelled: %v", m) + } + } + }) + } +} + +func TestFFmpegMalformedMediaFails(t *testing.T) { + e := NewEngine(nil) + defer e.Close() + _, err := e.RunString(`(async()=>{ + const source=()=>new ReadableStream({start(c){c.enqueue(new Uint8Array([1,2,3]));c.close();}}); + return await new Response(__gopeed_ffmpeg.merge({inputs:()=>({video:source(),audio:source()})})).arrayBuffer(); + })()`) + if err == nil || !strings.Contains(err.Error(), "ffmpeg") { + t.Fatalf("expected FFmpeg error: %v", err) + } +} + +func TestFFmpegBlobOpener(t *testing.T) { + var open stream.ObjectURLOpener + e := NewEngine(&Config{StreamConfig: &stream.Config{CreateObjectURL: func(opts *stream.ObjectURLOptions, opener stream.ObjectURLOpener) (string, error) { + if opts.Range || opts.Size != 0 { + t.Fatal("merged output must not advertise range or a guessed size") + } + open = opener + return "blob:ffmpeg-test", nil + }}}) + defer e.Close() + video, _ := os.ReadFile("testdata/ffmpeg/video.mp4") + audio, _ := os.ReadFile("testdata/ffmpeg/audio.mp4") + e.Runtime.Set("videoBytes", e.Runtime.NewArrayBuffer(video)) + e.Runtime.Set("audioBytes", e.Runtime.NewArrayBuffer(audio)) + _, err := e.RunString(`__gopeed_blob_create_object_url(()=>{ + const source=b=>new ReadableStream({start(c){c.enqueue(new Uint8Array(b));c.close();}}); + return __gopeed_ffmpeg.merge({inputs:()=>({video:source(videoBytes),audio:source(audioBytes)})}); + },{contentType:'video/mp4'})`) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + r, err := open(ctx, stream.ObjectURLOpenRequest{End: -1}) + if err != nil { + cancel() + t.Fatal(err) + } + b, err := io.ReadAll(r) + r.Close() + cancel() + if err != nil || !bytes.Contains(b, []byte("moof")) { + t.Fatalf("Blob read: %v (%d bytes)", err, len(b)) + } + } +} + +func TestFFmpegEngineCloseCancelsRequest(t *testing.T) { + started, closed := make(chan struct{}), make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(started) + <-r.Context().Done() + close(closed) + })) + defer srv.Close() + e := NewEngine(nil) + defer e.Close() + _, err := e.RunString(fmt.Sprintf(` + globalThis.cancelCount=0; + const audio=new ReadableStream({cancel(){cancelCount++;}}); + __gopeed_ffmpeg.merge({inputs:()=>({video:{url:%q},audio})}).getReader().read().catch(()=>{}); + "started";`, srv.URL)) + if err != nil { + t.Fatal(err) + } + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("HTTP did not start") + } + e.Close() + select { + case <-closed: + case <-time.After(5 * time.Second): + t.Fatal("HTTP leaked after engine shutdown") + } + if e.Runtime.Get("cancelCount").ToInteger() != 1 { + t.Fatal("JS input not cancelled on shutdown") + } +} + +func TestFFmpegInputFactoriesWaitForCapacity(t *testing.T) { + for _, mode := range []string{"factory", "http", "cancel"} { + t.Run(mode, func(t *testing.T) { + release1, _ := media.Acquire(context.Background()) + defer release1() + release2, _ := media.Acquire(context.Background()) + defer release2() + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1); http.Error(w, "test", 500) })) + defer srv.Close() + e := NewEngine(nil) + defer e.Close() + e.Runtime.Set("mode", mode) + e.Runtime.Set("sourceURL", srv.URL) + result, err := e.RunString(`(async()=>{ + globalThis.factoryCalls=0; + globalThis.abortQueue=new AbortController(); + const options=mode==='http'?{video:{url:sourceURL},audio:{url:sourceURL}}: + {inputs:()=>{factoryCalls++;throw new Error('factory failed');}}; + options.signal=abortQueue.signal; + globalThis.work=__gopeed_ffmpeg.merge(options).getReader().read().then(()=>'',e=>e.message); + await new Promise(r=>setTimeout(r,50)); + if(mode==='cancel')abortQueue.abort(); + return factoryCalls; + })()`) + if err != nil { + t.Fatal(err) + } + if fmt.Sprint(result) != "0" || requests.Load() != 0 { + t.Fatalf("queued inputs started: %v, requests=%d", result, requests.Load()) + } + if mode == "cancel" { + if _, err = e.RunString(`work`); err != nil { + t.Fatal(err) + } + } + release1() + if _, err = e.RunString(`work`); err != nil { + t.Fatal(err) + } + calls, err := e.RunString(`factoryCalls`) + if err != nil { + t.Fatal(err) + } + if mode == "factory" && fmt.Sprint(calls) != "1" { + t.Fatal(calls) + } + if mode == "cancel" && fmt.Sprint(calls) != "0" { + t.Fatal("cancelled factory ran") + } + if mode == "http" && requests.Load() != 1 { + t.Fatal("HTTP did not start after admission") + } + // Failure/cancellation must return the acquired slot even with the other held. + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + release, err := media.Acquire(ctx) + if err != nil { + t.Fatalf("slot leaked: %v", err) + } + release() + }) + } +} + +func TestFFmpegRequiresFactoryForStreams(t *testing.T) { + e := NewEngine(nil) + defer e.Close() + result, err := e.RunString(`(()=>{ + const source=()=>new ReadableStream({}); + let rejected=0; + for(const opts of [ + {video:source(),audio:source()}, + {video:{url:'https://example.test'},audio:source()}, + {inputs:()=>({}),video:{url:'https://example.test'}}, + {inputs:{video:source(),audio:source()}} + ]) {try{__gopeed_ffmpeg.merge(opts);}catch(e){if(e instanceof TypeError)rejected++;}} + return rejected; + })()`) + if err != nil || fmt.Sprint(result) != "4" { + t.Fatalf("validation: %v %v", result, err) + } +} + +func TestFFmpegCancelPendingFactoryCleansLateInputs(t *testing.T) { + e := NewEngine(nil) + defer e.Close() + result, err := e.RunString(`(async()=>{ + let ready,finish,cancelled=0,signal; + const entered=new Promise(r=>ready=r); + const abort=new AbortController(); + const output=__gopeed_ffmpeg.merge({signal:abort.signal,inputs:async opts=>{ + signal=opts.signal;ready();await new Promise(r=>finish=r); + const source=()=>new ReadableStream({cancel(){cancelled++;}}); + return {video:source(),audio:source()}; + }}); + const work=output.getReader().read().catch(e=>e.name); + await entered;abort.abort();await work;finish(); + await new Promise(r=>setTimeout(r,30)); + return {cancelled,aborted:signal.aborted}; + })()`) + if err != nil { + t.Fatal(err) + } + state := result.(map[string]any) + if fmt.Sprint(state["cancelled"]) != "2" || state["aborted"] != true { + t.Fatal(state) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + first, err := media.Acquire(ctx) + if err != nil { + t.Fatal(err) + } + defer first() + second, err := media.Acquire(ctx) + if err != nil { + t.Fatal("factory cancellation leaked capacity") + } + second() +} diff --git a/pkg/download/engine/inject/fetch/module.go b/pkg/download/engine/inject/fetch/module.go index f882fd7c..49ccf8cb 100644 --- a/pkg/download/engine/inject/fetch/module.go +++ b/pkg/download/engine/inject/fetch/module.go @@ -29,8 +29,9 @@ const FingerprintMagicKey = "__gopeed_fetch_fingerprint" var script string type Config struct { - ProxyHandler func(*http.Request) (*url.URL, error) - RegisterCleanup func(func()) + DefaultUserAgent *string + ProxyHandler func(*http.Request) (*url.URL, error) + RegisterCleanup func(func()) } func Enable(runtime *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error { @@ -51,6 +52,9 @@ func Enable(runtime *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error if err != nil { panic(runtime.NewGoError(err)) } + if cfg.DefaultUserAgent != nil && !hasHeader(request.Headers, "User-Agent") { + request.Headers = append(request.Headers, [2]string{"User-Agent", *cfg.DefaultUserAgent}) + } fingerprint := util.SafeGet[string](runtime, FingerprintMagicKey) promise, resolve, reject := runtime.NewPromise() go func() { diff --git a/pkg/download/engine/inject/ffmpeg/ffmpeg.js b/pkg/download/engine/inject/ffmpeg/ffmpeg.js new file mode 100644 index 00000000..49b42f25 --- /dev/null +++ b/pkg/download/engine/inject/ffmpeg/ffmpeg.js @@ -0,0 +1,139 @@ +(function () { + const create = __gopeed_ffmpeg_create; + const read = __gopeed_ffmpeg_read; + const acquire = __gopeed_ffmpeg_acquire; + const start = __gopeed_ffmpeg_start; + const push = __gopeed_ffmpeg_push; + const end = __gopeed_ffmpeg_end; + const cancel = __gopeed_ffmpeg_cancel; + const active = new Set(); + globalThis.__gopeed_ffmpeg_close_all = () => { + for (const stop of Array.from(active)) stop(new Error("extension engine closed")); + }; + + function input(value, name, allowStream) { + if (value && typeof value.getReader === "function") { + if (!allowStream) throw new TypeError("stream inputs must be created by the inputs callback"); + if (value.locked) throw new TypeError(name + " stream is locked"); + return { stream: value, request: null }; + } + if (!value || typeof value.url !== "string" || !/^https?:\/\//i.test(value.url)) { + throw new TypeError(name + " must be an HTTP request or ReadableStream"); + } + const headers = {}; + new Headers(value.headers || {}).forEach((v, k) => { headers[k] = v; }); + return { request: { url: value.url, headers } }; + } + + function merge(options) { + if (!options || typeof options !== "object") throw new TypeError("merge options are required"); + const factory = options.inputs; + if (factory !== undefined && typeof factory !== "function") throw new TypeError("inputs must be a callback"); + if (factory && (options.video !== undefined || options.audio !== undefined)) throw new TypeError("inputs cannot be combined with video/audio"); + let video = factory ? null : input(options.video, "video", false); + let audio = factory ? null : input(options.audio, "audio", false); + const args = options.args === undefined ? [] : options.args; + if (!Array.isArray(args) || args.some(v => typeof v !== "string")) throw new TypeError("args must be a string array"); + const format = options.format === undefined ? "mp4" : options.format; + if (typeof format !== "string") throw new TypeError("format must be a string"); + const signal = options.signal; + let id, controller, stopped = false; + const readers = []; + const lifetime = new AbortController(); + let ownedInputs; + function discardInputs(inputs, reason) { + for (const source of new Set([inputs && inputs.video, inputs && inputs.audio])) { + if (source && typeof source.cancel === "function" && !source.locked) Promise.resolve(source.cancel(reason)).catch(() => {}); + } + } + + function stop(reason) { + if (stopped) return; + stopped = true; + active.delete(shutdown); + lifetime.abort(); + discardInputs(ownedInputs, reason); + if (id) cancel(id); + if (signal) signal.removeEventListener("abort", abort); + for (const reader of readers) { + Promise.resolve(reader.cancel(reason)).catch(() => {}).then(() => { + try { reader.releaseLock(); } catch (_) {} + }); + } + } + function shutdown(reason) { + controller.error(reason); + stop(reason); + } + function abort() { + const error = new Error("FFmpeg operation aborted"); + error.name = "AbortError"; + stop(error); + controller.error(error); + } + async function pump(reader, index) { + try { + while (!stopped) { + const result = await reader.read(); + if (stopped) return; + if (result.done) { end(id, index, ""); return; } + if (!(result.value instanceof Uint8Array)) throw new TypeError("media streams must contain Uint8Array chunks"); + // Keep individual crossings bounded, even if upstream emits a large chunk. + for (let offset = 0; offset < result.value.byteLength && !stopped; offset += 256 * 1024) { + if (!await push(id, index, result.value.subarray(offset, offset + 256 * 1024))) return; + } + } + } catch (error) { + if (!stopped) { + end(id, index, String(error && error.message || error)); + controller.error(error); + stop(error); + } + } + } + return new ReadableStream({ + start(c) { + controller = c; + active.add(shutdown); + if (signal) { + if (signal.aborted) abort(); + else signal.addEventListener("abort", abort); + } + }, + async pull(c) { + if (stopped) return; + try { + if (!id) { + id = create({ format, args }); + await acquire(id); + if (stopped) return; + if (factory) { + const inputs = await factory({ signal: lifetime.signal }); + if (stopped) { discardInputs(inputs, new Error("FFmpeg operation cancelled")); return; } + ownedInputs = inputs; + video = input(inputs && inputs.video, "video", true); + audio = input(inputs && inputs.audio, "audio", true); + } + if (video.stream && video.stream === audio.stream) throw new TypeError("video and audio must be distinct streams"); + for (const source of [video, audio]) { + if (source.stream) readers.push(source.stream.getReader()); + } + start(id, { video: video.request, audio: audio.request, format, args }); + let readerIndex = 0; + for (const [index, source] of [video, audio].entries()) { + if (source.stream) void pump(readers[readerIndex++], index); + } + } + const chunk = await read(id); + if (stopped) return; + if (chunk === null) { stop(); c.close(); } + else c.enqueue(new Uint8Array(chunk)); + } catch (error) { + if (!stopped) { c.error(error); stop(error); } + } + }, + cancel(reason) { stop(reason); } + }); + } + globalThis.__gopeed_ffmpeg = Object.freeze({ merge, supportsInputFactory: true }); +})(); diff --git a/pkg/download/engine/inject/ffmpeg/module.go b/pkg/download/engine/inject/ffmpeg/module.go new file mode 100644 index 00000000..cc73e990 --- /dev/null +++ b/pkg/download/engine/inject/ffmpeg/module.go @@ -0,0 +1,324 @@ +package ffmpeg + +import ( + "context" + _ "embed" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "sync/atomic" + + "github.com/GopeedLab/gopeed/internal/httpclient" + media "github.com/GopeedLab/gopeed/pkg/download/engine/ffmpeg" + "github.com/dop251/goja" + "github.com/dop251/goja_nodejs/eventloop" +) + +//go:embed ffmpeg.js +var script string + +type Config struct { + DefaultUserAgent *string + ProxyHandler func(*http.Request) (*url.URL, error) + RegisterCleanup func(func()) +} +type options struct { + Video *media.HTTPSource `json:"video"` + Audio *media.HTTPSource `json:"audio"` + Format string `json:"format"` + Args []string `json:"args"` +} +type job struct { + ctx context.Context + cancel context.CancelFunc + output *io.PipeReader + streams [2]*media.StreamInput + mu sync.Mutex + ready chan struct{} + start chan options + started bool +} + +func (j *job) stop() { + j.cancel() + j.output.CloseWithError(context.Canceled) + j.mu.Lock() + defer j.mu.Unlock() + for _, s := range j.streams { + if s != nil { + s.Close() + } + } +} + +func Enable(vm *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error { + if cfg == nil { + cfg = &Config{} + } + client, err := httpclient.NewClient(httpclient.Options{Transport: httpclient.TransportOptions{Proxy: cfg.ProxyHandler}}) + if err != nil { + return err + } + var mu sync.Mutex + jobs := map[string]*job{} + var next atomic.Uint64 + get := func(id string) *job { mu.Lock(); defer mu.Unlock(); return jobs[id] } + remove := func(id string) { + mu.Lock() + j := jobs[id] + delete(jobs, id) + mu.Unlock() + if j != nil { + j.stop() + } + } + if cfg.RegisterCleanup != nil { + cfg.RegisterCleanup(func() { + done := make(chan struct{}) + if loop.RunOnLoop(func(vm *goja.Runtime) { + defer close(done) + if closeAll, ok := goja.AssertFunction(vm.Get("__gopeed_ffmpeg_close_all")); ok { + _, _ = closeAll(goja.Undefined()) + } + }) { + <-done + } + mu.Lock() + old := jobs + jobs = map[string]*job{} + mu.Unlock() + for _, j := range old { + j.stop() + } + client.CloseIdleConnections() + }) + } + if err := vm.Set("__gopeed_ffmpeg_create", func(call goja.FunctionCall) goja.Value { + var opts options + if err := vm.ExportTo(call.Argument(0), &opts); err != nil { + panic(vm.NewGoError(err)) + } + if _, err := media.Arguments(opts.Format, opts.Args); err != nil { + panic(vm.NewGoError(err)) + } + ctx, cancel := context.WithCancel(context.Background()) + r, w := io.Pipe() + j := &job{ctx: ctx, cancel: cancel, output: r, ready: make(chan struct{}), start: make(chan options, 1)} + id := fmt.Sprintf("ffmpeg-%d", next.Add(1)) + mu.Lock() + jobs[id] = j + mu.Unlock() + go func() { + var inputs [2]media.Input + var runErr error + defer func() { + if v := recover(); v != nil { + runErr = fmt.Errorf("ffmpeg panic: %v", v) + } + cancel() + for _, in := range inputs { + if in != nil { + in.Close() + } + } + w.CloseWithError(runErr) + }() + release, err := media.Acquire(ctx) + if err != nil { + runErr = err + return + } + defer release() + close(j.ready) + var opts options + select { + case opts = <-j.start: + case <-ctx.Done(): + runErr = ctx.Err() + return + } + for i, source := range []*media.HTTPSource{opts.Video, opts.Audio} { + if source == nil { + inputs[i] = j.streams[i] + } else { + inputs[i], runErr = media.OpenHTTP(ctx, client, *source) + if runErr != nil { + return + } + } + } + runErr = media.RunAcquired(ctx, inputs[0], inputs[1], w, opts.Format, opts.Args) + }() + return vm.ToValue(id) + }); err != nil { + return err + } + if err := vm.Set("__gopeed_ffmpeg_acquire", func(id string) goja.Value { + promise, resolve, reject := vm.NewPromise() + j := get(id) + go func() { + var err error + if j == nil { + err = io.ErrClosedPipe + } else { + select { + case <-j.ready: + case <-j.ctx.Done(): + err = j.ctx.Err() + } + if j.ctx.Err() != nil { + err = j.ctx.Err() + } + } + loop.RunOnLoop(func(vm *goja.Runtime) { + if err != nil { + reject(vm.NewGoError(err)) + } else { + resolve(true) + } + }) + }() + return vm.ToValue(promise) + }); err != nil { + return err + } + if err := vm.Set("__gopeed_ffmpeg_start", func(call goja.FunctionCall) goja.Value { + id := call.Argument(0).String() + j := get(id) + if j == nil { + panic(vm.NewGoError(io.ErrClosedPipe)) + } + var opts options + if err := vm.ExportTo(call.Argument(1), &opts); err != nil { + panic(vm.NewGoError(err)) + } + if _, err := media.Arguments(opts.Format, opts.Args); err != nil { + panic(vm.NewGoError(err)) + } + for _, source := range []*media.HTTPSource{opts.Video, opts.Audio} { + if source == nil || cfg.DefaultUserAgent == nil { + continue + } + specified := false + for key := range source.Headers { + if strings.EqualFold(key, "User-Agent") { + specified = true + break + } + } + if !specified { + if source.Headers == nil { + source.Headers = make(map[string]string) + } + source.Headers["User-Agent"] = *cfg.DefaultUserAgent + } + } + + j.mu.Lock() + defer j.mu.Unlock() + if j.started || j.ctx.Err() != nil { + panic(vm.NewGoError(io.ErrClosedPipe)) + } + j.started = true + for i, source := range []*media.HTTPSource{opts.Video, opts.Audio} { + if source == nil { + j.streams[i] = media.NewStreamInput(j.ctx) + } + } + j.start <- opts + return goja.Undefined() + }); err != nil { + return err + } + if err := vm.Set("__gopeed_ffmpeg_read", func(id string) goja.Value { + promise, resolve, reject := vm.NewPromise() + go func() { + var readErr error + buf := make([]byte, 64*1024) + n := 0 + j := get(id) + if j == nil { + readErr = io.ErrClosedPipe + } else { + n, readErr = j.output.Read(buf) + } + if !loop.RunOnLoop(func(vm *goja.Runtime) { + if readErr != nil { + remove(id) + if readErr == io.EOF { + resolve(goja.Null()) + } else { + reject(vm.NewGoError(readErr)) + } + return + } + resolve(vm.ToValue(vm.NewArrayBuffer(buf[:n]))) + }) { + remove(id) + } + }() + return vm.ToValue(promise) + }); err != nil { + return err + } + if err := vm.Set("__gopeed_ffmpeg_push", func(call goja.FunctionCall) goja.Value { + id := call.Argument(0).String() + index := int(call.Argument(1).ToInteger()) + var chunk []byte + switch b := call.Argument(2).Export().(type) { + case goja.ArrayBuffer: + chunk = append([]byte(nil), b.Bytes()...) + case []byte: + chunk = append([]byte(nil), b...) + default: + panic(vm.NewTypeError("ffmpeg input requires bytes")) + } + promise, resolve, reject := vm.NewPromise() + go func() { + var err error + j := get(id) + if j == nil || index < 0 || index > 1 || j.streams[index] == nil { + err = io.ErrClosedPipe + } else { + _, err = j.streams[index].Write(chunk) + } + if err != nil && j != nil && index >= 0 && index < 2 && j.streams[index] != nil { + j.streams[index].End(err) + } + loop.RunOnLoop(func(vm *goja.Runtime) { + if j != nil && j.ctx.Err() != nil { + resolve(false) + return + } + if err != nil { + reject(vm.NewGoError(err)) + } else { + resolve(true) + } + }) + }() + return vm.ToValue(promise) + }); err != nil { + return err + } + if err := vm.Set("__gopeed_ffmpeg_end", func(id string, index int, message string) { + if j := get(id); j != nil && index >= 0 && index < 2 && j.streams[index] != nil { + var err error + if message != "" { + err = errors.New(message) + } + j.streams[index].End(err) + } + }); err != nil { + return err + } + if err := vm.Set("__gopeed_ffmpeg_cancel", remove); err != nil { + return err + } + _, err = vm.RunString(script) + return err +} diff --git a/pkg/download/engine/inject/stream/module.go b/pkg/download/engine/inject/stream/module.go index 5aaa214f..89db11fc 100644 --- a/pkg/download/engine/inject/stream/module.go +++ b/pkg/download/engine/inject/stream/module.go @@ -86,18 +86,23 @@ func Enable(runtime *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error } if err := runtime.Set("__gopeed_blob_pipe_chunk", func(call goja.FunctionCall) goja.Value { id := call.Argument(0).String() - ok := blobPipes.Push(id, exportBytes(call.Argument(1))) - return runtime.ToValue(ok) + chunk := append([]byte(nil), exportBytes(call.Argument(1))...) + promise, resolve, _ := runtime.NewPromise() + go func() { + ok := blobPipes.Push(id, chunk) + loop.RunOnLoop(func(vm *goja.Runtime) { resolve(ok) }) + }() + return runtime.ToValue(promise) }); err != nil { return err } if err := runtime.Set("__gopeed_blob_pipe_close", func(id string) { - blobPipes.Close(id, nil) + go blobPipes.Close(id, nil) }); err != nil { return err } if err := runtime.Set("__gopeed_blob_pipe_error", func(id string, message string) { - blobPipes.Close(id, errors.New(message)) + go blobPipes.Close(id, errors.New(message)) }); err != nil { return err } diff --git a/pkg/download/engine/inject/stream/module_integration_test.go b/pkg/download/engine/inject/stream/module_integration_test.go index b2c11cec..e9e07411 100644 --- a/pkg/download/engine/inject/stream/module_integration_test.go +++ b/pkg/download/engine/inject/stream/module_integration_test.go @@ -269,3 +269,57 @@ func newStreamTestEngine(t *testing.T, created chan<- createdObjectURL) (*engine }) return e, e.Close } + +// A stalled disk/network consumer must pause the producer without blocking +// the JS event loop, so cancellation and other tracks can still run. +func TestBlobBackpressureLeavesEventLoopResponsive(t *testing.T) { + created := make(chan createdObjectURL, 1) + e, cleanup := newStreamTestEngine(t, created) + defer cleanup() + _, err := e.RunString(` + globalThis.produced = 0; + globalThis.sourceCancelled = false; + __gopeed_blob_create_object_url(() => new ReadableStream({ + pull(c) { produced++; c.enqueue(new Uint8Array(64*1024)); }, + cancel() { sourceCancelled = true; } + })); + `) + if err != nil { + t.Fatal(err) + } + object := <-created + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + reader, err := object.open(ctx, stream.ObjectURLOpenRequest{End: -1}) + if err != nil { + t.Fatal(err) + } + defer reader.Close() + time.Sleep(100 * time.Millisecond) + value, err := e.RunString("produced") + if err != nil { + t.Fatal(err) + } + before := value.(int64) + if before > 10 { + t.Fatalf("unbounded production: %d", before) + } + time.Sleep(50 * time.Millisecond) + after, err := e.RunString("produced") + if err != nil || after != value { + t.Fatalf("producer did not pause: %v -> %v (%v)", value, after, err) + } + if ctx.Err() != nil { + t.Fatal("event loop only resumed after cancellation") + } + if _, err = reader.Read(make([]byte, 64*1024)); err != nil { + t.Fatal(err) + } + time.Sleep(30 * time.Millisecond) + after, err = e.RunString("produced") + if err != nil || after.(int64) <= before { + t.Fatalf("producer did not resume: %v (%v)", after, err) + } + reader.Close() + waitForJSValue(t, e, "String(sourceCancelled)", "true") +} diff --git a/pkg/download/engine/inject/stream/stream.js b/pkg/download/engine/inject/stream/stream.js index ea3bd85b..829a9344 100644 --- a/pkg/download/engine/inject/stream/stream.js +++ b/pkg/download/engine/inject/stream/stream.js @@ -105,6 +105,10 @@ return Promise.resolve({ done: true, value: undefined }); } this._maybePull(); + // pull() may synchronously error before a waiter has been registered. + if (this._errored) { + return Promise.reject(this._errored); + } if (this._queue.length > 0) { return Promise.resolve({ done: false, value: this._queue.shift() }); } @@ -760,11 +764,14 @@ return; } const chunk = value instanceof Uint8Array ? value : new Uint8Array(value); - const buffer = chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength); - if (!globalThis.__gopeed_blob_pipe_chunk(pipeId, buffer)) { - state.cancelRequested = true; - state.cancelReason = "blob pipe closed"; - return; + for (let offset = 0; offset < chunk.byteLength; offset += 256 * 1024) { + const part = chunk.subarray(offset, offset + 256 * 1024); + const buffer = part.buffer.slice(part.byteOffset, part.byteOffset + part.byteLength); + if (!await globalThis.__gopeed_blob_pipe_chunk(pipeId, buffer)) { + state.cancelRequested = true; + state.cancelReason = "blob pipe closed"; + return; + } } await yieldBlobPipeTask(); } diff --git a/pkg/download/engine/testdata/ffmpeg/README.md b/pkg/download/engine/testdata/ffmpeg/README.md new file mode 100644 index 00000000..ba3e5992 --- /dev/null +++ b/pkg/download/engine/testdata/ffmpeg/README.md @@ -0,0 +1,18 @@ +# Synthetic media fixtures + +These files contain 0.3 seconds of a generated 32x32 black image (H.264) and +generated mono silence (AAC). They contain no third-party media. Generated +with the pinned go-ffmpreg FFmpeg binary using the equivalent commands: + +``` +ffmpeg -f lavfi -i color=c=black:s=32x32:r=10 -t 0.3 -c:v libx264 -preset ultrafast -movflags frag_keyframe+empty_moov -f mp4 video.mp4 +ffmpeg -f lavfi -i anullsrc=r=48000:cl=mono -t 0.3 -c:a aac -movflags frag_keyframe+empty_moov -f mp4 audio.mp4 +``` + +The backend tests also generate and probe media using the embedded FFmpeg and +FFprobe at test time, without relying on a system installation. + +`video.webm` and `audio.webm` use the same black/silent sources, encoded once +with native FFmpeg 7.1 using `-c:v libvpx-vp9` and `-c:a libopus`. The committed +fixtures let the tests verify WASM stream-copy remuxing without exercising its +VP9 encoder or requiring native FFmpeg on developer/CI machines. diff --git a/pkg/download/engine/testdata/ffmpeg/audio.mp4 b/pkg/download/engine/testdata/ffmpeg/audio.mp4 new file mode 100644 index 00000000..1a16c3c4 Binary files /dev/null and b/pkg/download/engine/testdata/ffmpeg/audio.mp4 differ diff --git a/pkg/download/engine/testdata/ffmpeg/audio.webm b/pkg/download/engine/testdata/ffmpeg/audio.webm new file mode 100644 index 00000000..6e06a4a2 Binary files /dev/null and b/pkg/download/engine/testdata/ffmpeg/audio.webm differ diff --git a/pkg/download/engine/testdata/ffmpeg/video.mp4 b/pkg/download/engine/testdata/ffmpeg/video.mp4 new file mode 100644 index 00000000..d5d3d385 Binary files /dev/null and b/pkg/download/engine/testdata/ffmpeg/video.mp4 differ diff --git a/pkg/download/engine/testdata/ffmpeg/video.webm b/pkg/download/engine/testdata/ffmpeg/video.webm new file mode 100644 index 00000000..f88aa145 Binary files /dev/null and b/pkg/download/engine/testdata/ffmpeg/video.webm differ diff --git a/pkg/download/engine/webview/rpc.go b/pkg/download/engine/webview/rpc.go index 5dab17ef..77c91976 100644 --- a/pkg/download/engine/webview/rpc.go +++ b/pkg/download/engine/webview/rpc.go @@ -5,6 +5,7 @@ import "encoding/json" const RPCEndpointPath = "/webview" const ( + MethodProfileRemove = "profile.remove" MethodIsAvailable = "webview.isAvailable" MethodPageOpen = "page.open" MethodPageAddInitScript = "page.addInitScript" @@ -68,6 +69,9 @@ type IsAvailableResult struct { } type PageOpenParams struct { + ProfileID string `json:"profileId,omitempty"` + DataPath string `json:"dataPath,omitempty"` + ProxyURL string `json:"proxyUrl,omitempty"` Headless bool `json:"headless,omitempty"` Debug bool `json:"debug,omitempty"` Title string `json:"title,omitempty"` @@ -78,6 +82,9 @@ type PageOpenParams struct { func NewPageOpenParams(opts OpenOptions) PageOpenParams { return PageOpenParams{ + ProfileID: opts.ProfileID, + DataPath: opts.DataPath, + ProxyURL: opts.ProxyURL, Headless: opts.Headless, Debug: opts.Debug, Title: opts.Title, @@ -141,3 +148,8 @@ type PageCloseParams struct { } type EmptyResult struct{} + +// ProfileRemoveParams belongs to the private host RPC. +type ProfileRemoveParams struct { + ProfileID string `json:"profileId"` +} diff --git a/pkg/download/engine/webview/runtime.go b/pkg/download/engine/webview/runtime.go index 64a236e4..cc115826 100644 --- a/pkg/download/engine/webview/runtime.go +++ b/pkg/download/engine/webview/runtime.go @@ -17,6 +17,11 @@ type Opener interface { Open(opts OpenOptions) (Page, error) } +// ProfileRemover is a host-only lifecycle operation, never exposed to extension JS. +type ProfileRemover interface { + RemoveProfile(profileID, dataPath string) error +} + type Page interface { AddInitScript(script string) error Goto(url string, opts GotoOptions) error @@ -29,6 +34,10 @@ type Page interface { } type OpenOptions struct { + // Host-owned fields are never parsed from extension JavaScript options. + ProfileID string + DataPath string + ProxyURL string Headless bool Debug bool Title string diff --git a/pkg/download/engine/webview/runtime_test.go b/pkg/download/engine/webview/runtime_test.go index 066dea2e..6f963055 100644 --- a/pkg/download/engine/webview/runtime_test.go +++ b/pkg/download/engine/webview/runtime_test.go @@ -336,3 +336,15 @@ func (p *fakePage) Close() error { p.closed = true return nil } + +func TestExtensionCannotSupplyHostProfileOrProxy(t *testing.T) { + opener := &fakeOpener{page: &fakePage{}} + runtime := NewRuntime(opener, true) + _, err := runtime.Open(map[string]any{"profileId": "other-extension", "dataPath": "/tmp/other-profile", "proxyUrl": "http://example.com:1234", "ProfileID": "other"}) + if err != nil { + t.Fatal(err) + } + if opener.opts.ProfileID != "" || opener.opts.DataPath != "" || opener.opts.ProxyURL != "" { + t.Fatalf("extension controlled host settings: %+v", opener.opts) + } +} diff --git a/pkg/download/extension.go b/pkg/download/extension.go index c9bb7832..53f9e989 100644 --- a/pkg/download/extension.go +++ b/pkg/download/extension.go @@ -65,6 +65,7 @@ func (d *Downloader) InstallExtensionByFolder(path string, devMode bool) (*Exten // if extension is not installed, add it to the list, otherwise update it installedExt := d.getExtension(ext.Identity) + newInstallation := installedExt == nil if installedExt == nil { ext.CreatedAt = time.Now() ext.UpdatedAt = ext.CreatedAt @@ -76,6 +77,11 @@ func (d *Downloader) InstallExtensionByFolder(path string, devMode bool) (*Exten if err = d.storage.Put(bucketExtension, installedExt.Identity, installedExt); err != nil { return nil, err } + if newInstallation { + d.webviewProfilesLock.Lock() + delete(d.webviewProfiles, ext.Identity) + d.webviewProfilesLock.Unlock() + } return installedExt, nil } @@ -144,6 +150,9 @@ func (d *Downloader) DeleteExtension(identity string) error { if err != nil { return err } + if err := d.removeExtensionWebViewProfile(identity); err != nil { + return fmt.Errorf("remove extension WebView data: %w", err) + } // remove from disk if !ext.DevMode { if err := os.RemoveAll(d.ExtensionPath(ext)); err != nil { @@ -365,7 +374,7 @@ func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, c engine, session := d.newExtensionEngine() defer session.CloseIfIdle() gopeed.Runtime = &InstanceRuntime{ - WebView: d.newExtensionWebViewRuntime(session), + WebView: d.newExtensionWebViewRuntime(session, ext.buildIdentity()), } err = injectGopeed(engine.Runtime, gopeed) if err != nil { diff --git a/pkg/download/extension_engine.go b/pkg/download/extension_engine.go index 39b832f1..4ef05d23 100644 --- a/pkg/download/extension_engine.go +++ b/pkg/download/extension_engine.go @@ -30,7 +30,7 @@ func (d *Downloader) NewExtensionEngine(ext *Extension, settings map[string]any) identity: ext.buildIdentity(), }, Runtime: &InstanceRuntime{ - WebView: d.newExtensionWebViewRuntime(session), + WebView: d.newExtensionWebViewRuntime(session, ext.buildIdentity()), }, } if err := injectGopeed(engine.Runtime, gopeed); err != nil { diff --git a/pkg/download/extension_engine_session.go b/pkg/download/extension_engine_session.go index 451a2764..45b89dcb 100644 --- a/pkg/download/extension_engine_session.go +++ b/pkg/download/extension_engine_session.go @@ -3,9 +3,11 @@ package download import ( "context" "io" + "strings" "sync" internalblob "github.com/GopeedLab/gopeed/internal/blob" + httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http" "github.com/GopeedLab/gopeed/pkg/download/engine" "github.com/GopeedLab/gopeed/pkg/download/engine/inject/stream" enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" @@ -119,21 +121,27 @@ func (d *Downloader) newExtensionEngine() (*engine.Engine, *engineSession) { return d.blob.Revoke(url) }, } + httpConfig := struct { + UserAgent string `json:"userAgent"` + }{UserAgent: httpProtocol.DefaultUserAgent} + d.getProtocolConfig("http", &httpConfig) + userAgent := strings.TrimSpace(httpConfig.UserAgent) e := engine.NewEngine(&engine.Config{ - ProxyConfig: d.cfg.Proxy, - StreamConfig: engineCfg, + HTTPUserAgent: &userAgent, + ProxyConfig: d.cfg.Proxy, + StreamConfig: engineCfg, }) session.SetEngine(e) return e, session } -func (d *Downloader) newExtensionWebViewRuntime(session *engineSession) *enginewebview.Runtime { +func (d *Downloader) newExtensionWebViewRuntime(session *engineSession, identity string) *enginewebview.Runtime { var ( opener enginewebview.Opener available bool ) if provider := d.cfg.WebViewProvider; provider != nil && provider.IsAvailable() { - opener = provider + opener = &extensionWebViewOpener{downloader: d, provider: provider, identity: identity, profile: d.extensionWebViewProfile(identity)} available = true } runtime := enginewebview.NewRuntime(opener, available) diff --git a/pkg/download/extension_runtime_webview.go b/pkg/download/extension_runtime_webview.go index c9bb26b1..25d07cad 100644 --- a/pkg/download/extension_runtime_webview.go +++ b/pkg/download/extension_runtime_webview.go @@ -30,6 +30,9 @@ func injectGopeed(vm *goja.Runtime, gopeed *Instance) error { } runtimeObject := vm.NewObject() if gopeed.Runtime != nil { + if err := runtimeObject.Set("ffmpeg", vm.Get("__gopeed_ffmpeg")); err != nil { + return err + } if err := runtimeObject.Set("blob", newJSBlobRuntime(vm)); err != nil { return err } diff --git a/pkg/download/extension_test.go b/pkg/download/extension_test.go index b78380d5..ef5ff8bd 100644 --- a/pkg/download/extension_test.go +++ b/pkg/download/extension_test.go @@ -1484,6 +1484,7 @@ func TestDownloader_ExtensionRuntimeWebViewInjected(t *testing.T) { value, err := runtime.Eval(`(async () => ({ hasRuntime: !!gopeed.runtime, hasBlob: !!(gopeed.runtime && gopeed.runtime.blob), + hasFFmpegMerge: typeof gopeed.runtime.ffmpeg.merge, hasCreateObjectURL: typeof gopeed.runtime.blob.createObjectURL, hasRevokeObjectURL: typeof gopeed.runtime.blob.revokeObjectURL, hasWebView: !!(gopeed.runtime && gopeed.runtime.webview), @@ -1502,6 +1503,9 @@ func TestDownloader_ExtensionRuntimeWebViewInjected(t *testing.T) { if result["hasRuntime"] != true { t.Fatalf("expected runtime injection, got %#v", result) } + if result["hasFFmpegMerge"] != "function" { + t.Fatalf("expected ffmpeg injection, got %#v", result) + } if result["hasBlob"] != true { t.Fatalf("expected blob injection, got %#v", result) } diff --git a/pkg/download/extension_unnamed_resource_test.go b/pkg/download/extension_unnamed_resource_test.go new file mode 100644 index 00000000..7ef765eb --- /dev/null +++ b/pkg/download/extension_unnamed_resource_test.go @@ -0,0 +1,37 @@ +package download + +import ( + "github.com/GopeedLab/gopeed/pkg/base" + "os" + "path/filepath" + "testing" +) + +func TestExtensionUnnamedResource(t *testing.T) { + d, cleanup, err := newTestExtensionEngineDownloader() + if err != nil { + t.Fatal(err) + } + defer cleanup() + dir := t.TempDir() + manifest := `{"name":"unnamed","title":"Unnamed test","author":"test","version":"1.0.0","scripts":[{"event":"onResolve","match":{"urls":["https://example.com/unnamed"]},"entry":"index.js"}]}` + script := `gopeed.events.onResolve(async(ctx)=>{ctx.res={range:false,files:[{name:'video.mp4',req:{url:await gopeed.runtime.blob.createObjectURL(()=>new ReadableStream({start(c){c.enqueue(new Uint8Array([1,2,3]));c.close();}}))}}]};});` + for name, data := range map[string]string{"manifest.json": manifest, "index.js": script} { + if err = os.WriteFile(filepath.Join(dir, name), []byte(data), 0600); err != nil { + t.Fatal(err) + } + } + if _, err = d.InstallExtensionByFolder(dir, true); err != nil { + t.Fatal(err) + } + res, err := d.Resolve(&base.Request{URL: "https://example.com/unnamed"}, nil) + if err != nil { + t.Fatal(err) + } + if res.Res.Name != "" || len(res.Res.Files) != 1 || res.Res.Files[0].Name != "video.mp4" { + t.Fatalf("unexpected resource: %+v", res.Res) + } + if !d.blob.IsURL(res.Res.Files[0].Req.URL) { + t.Fatal("extension result was discarded") + } +} diff --git a/pkg/download/extension_user_agent_test.go b/pkg/download/extension_user_agent_test.go new file mode 100644 index 00000000..627a6f73 --- /dev/null +++ b/pkg/download/extension_user_agent_test.go @@ -0,0 +1,108 @@ +package download + +import ( + "bytes" + "fmt" + httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +func TestExtensionHTTPUserAgent(t *testing.T) { + video, err := os.ReadFile("engine/testdata/ffmpeg/video.mp4") + if err != nil { + t.Fatal(err) + } + audio, err := os.ReadFile("engine/testdata/ffmpeg/audio.mp4") + if err != nil { + t.Fatal(err) + } + for _, configured := range []string{" Configured-UA/1.0 ", "", ""} { + for _, header := range []string{"default", "custom", "empty"} { + t.Run(fmt.Sprintf("configured=%q/header=%s", configured, header), func(t *testing.T) { + expected := strings.TrimSpace(configured) + if configured == "" { + expected = httpProtocol.DefaultUserAgent + } + headers := `{}` + if header == "custom" { + expected = "Explicit-UA/2.0" + headers = `{"uSeR-aGeNt":"Explicit-UA/2.0"}` + } + if header == "empty" { + expected = "" + headers = `{"user-agent":""}` + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.UserAgent() != expected { + t.Errorf("%s UA=%q, want %q", r.URL.Path, r.UserAgent(), expected) + } + if strings.HasPrefix(r.URL.Path, "/redirect/") { + http.Redirect(w, r, strings.TrimPrefix(r.URL.Path, "/redirect"), http.StatusFound) + return + } + data := video + if strings.HasSuffix(r.URL.Path, "audio") { + data = audio + } + http.ServeContent(w, r, "media", time.Time{}, bytes.NewReader(data)) + })) + defer server.Close() + d, cleanup, err := newTestExtensionEngineDownloader() + if err != nil { + t.Fatal(err) + } + defer cleanup() + cfg, err := d.GetConfig() + if err != nil { + t.Fatal(err) + } + cfg.ProtocolConfig["http"] = map[string]any{"userAgent": configured} + if configured == "" { + cfg.ProtocolConfig["http"] = map[string]any{"connections": 4} + } + if err = d.PutConfig(cfg); err != nil { + t.Fatal(err) + } + runtime, err := newTestExtensionEngine(t, d) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + // The existing engine keeps its UA snapshot when the downloader config changes. + cfg, err = d.GetConfig() + if err != nil { + t.Fatal(err) + } + cfg.ProtocolConfig["http"] = map[string]any{"userAgent": "Next-engine-UA"} + if err = d.PutConfig(cfg); err != nil { + t.Fatal(err) + } + result, err := runtime.Eval(fmt.Sprintf(`(async()=>{ + const headers=%s, url=%q; + await (await fetch(url+'/redirect/fetch',{headers})).arrayBuffer(); + await new Promise((resolve,reject)=>{ + const xhr=new XMLHttpRequest();xhr.open('GET',url+'/redirect/xhr'); + for(const key of Object.keys(headers))xhr.setRequestHeader(key,headers[key]); + xhr.onload=resolve;xhr.onerror=()=>reject(new Error('XHR failed'));xhr.send(); + }); + // Profile defaults must not replace the configured or explicitly empty UA. + __gopeed_setFingerprint('chrome'); + await (await fetch(url+'/fingerprint',{headers})).arrayBuffer(); + const output=gopeed.runtime.ffmpeg.merge({video:{url:url+'/redirect/video',headers},audio:{url:url+'/redirect/audio',headers}}); + return (await new Response(output).arrayBuffer()).byteLength > 0; + })()`, headers, server.URL)) + if err != nil { + t.Fatal(err) + } + if result != true { + t.Fatalf("merge result=%v", result) + } + }) + } + } +} diff --git a/pkg/download/extension_webview_config.go b/pkg/download/extension_webview_config.go new file mode 100644 index 00000000..164df1e4 --- /dev/null +++ b/pkg/download/extension_webview_config.go @@ -0,0 +1,183 @@ +package download + +import ( + "crypto/sha256" + "fmt" + "net/http" + "net/url" + "os" + "sync" + + "github.com/GopeedLab/gopeed/pkg/base" + "path/filepath" + "runtime" + + webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy" + enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" +) + +// extensionWebViewOpener binds browser state to the owning extension. Neither +// identity, filesystem paths nor proxy settings are accepted from JavaScript. +type extensionWebViewOpener struct { + downloader *Downloader + provider enginewebview.Provider + identity string + profile *extensionWebViewProfile +} + +func (o *extensionWebViewOpener) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) { + if o.identity == "" { + return nil, fmt.Errorf("webview extension identity is required") + } + profile := o.profile + if profile == nil { + profile = o.downloader.extensionWebViewProfile(o.identity) + } + profile.mu.Lock() + defer profile.mu.Unlock() + if profile.revoked { + return nil, fmt.Errorf("extension WebView profile has been removed") + } + id, path, err := o.downloader.extensionWebViewLocation(o.identity) + if err != nil { + return nil, err + } + opts.ProfileID, opts.DataPath = id, path + opts.ProxyURL, err = o.downloader.webviewProxyURL() + if err != nil { + return nil, err + } + page, err := o.provider.Open(opts) + if err != nil { + return nil, err + } + tracked := &extensionWebViewPage{Page: page, profile: profile} + profile.pages[tracked] = struct{}{} + return tracked, nil +} + +func (d *Downloader) webviewProxyURL() (string, error) { + d.webviewProxyLock.Lock() + defer d.webviewProxyLock.Unlock() + if d.closed.Load() { + return "", fmt.Errorf("downloader is closed") + } + if d.webviewProxy == nil { + server, err := webviewproxy.New(func(r *http.Request) (*url.URL, error) { + if d.blob != nil && d.blob.IsURL(r.URL.String()) { + return nil, nil + } + d.configLock.RLock() + var cfg base.DownloaderProxyConfig + if d.cfg.Proxy != nil { + cfg = *d.cfg.Proxy + } + d.configLock.RUnlock() + handler := cfg.ToHandler() + if handler == nil { + return nil, nil + } + return handler(r) + }) + if err != nil { + return "", err + } + d.webviewProxy = server + } + if runtime.GOOS == "darwin" || runtime.GOOS == "ios" { + return d.webviewProxy.SOCKSURL(), nil + } + return d.webviewProxy.URL(), nil +} + +// A revoked profile is retained until a fresh installation. Existing runtimes +// retain this object, so reinstalling cannot let an old runtime recreate data. +type extensionWebViewProfile struct { + mu sync.Mutex + deleteMu sync.Mutex + revoked bool + pages map[*extensionWebViewPage]struct{} +} + +type extensionWebViewPage struct { + enginewebview.Page + profile *extensionWebViewProfile + mu sync.Mutex + closed bool +} + +func (p *extensionWebViewPage) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + if p.closed { + return nil + } + if err := p.Page.Close(); err != nil { + return err + } + p.closed = true + p.profile.mu.Lock() + delete(p.profile.pages, p) + p.profile.mu.Unlock() + return nil +} + +func (d *Downloader) extensionWebViewProfile(identity string) *extensionWebViewProfile { + d.webviewProfilesLock.Lock() + defer d.webviewProfilesLock.Unlock() + if d.webviewProfiles == nil { + d.webviewProfiles = make(map[string]*extensionWebViewProfile) + } + profile := d.webviewProfiles[identity] + if profile == nil { + profile = &extensionWebViewProfile{pages: make(map[*extensionWebViewPage]struct{})} + d.webviewProfiles[identity] = profile + } + return profile +} + +func (d *Downloader) extensionWebViewLocation(identity string) (string, string, error) { + if identity == "" { + return "", "", fmt.Errorf("webview extension identity is required") + } + sum := sha256.Sum256([]byte(identity)) + id := fmt.Sprintf("%x-%x-%x-%x-%x", sum[:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16]) + path, err := filepath.Abs(filepath.Join(d.cfg.StorageDir, "webview", fmt.Sprintf("%x", sum[:]))) + return id, path, err +} + +func (d *Downloader) removeExtensionWebViewProfile(identity string) error { + profile := d.extensionWebViewProfile(identity) + profile.deleteMu.Lock() + defer profile.deleteMu.Unlock() + profile.mu.Lock() + profile.revoked = true + pages := make([]*extensionWebViewPage, 0, len(profile.pages)) + for page := range profile.pages { + pages = append(pages, page) + } + profile.mu.Unlock() + for _, page := range pages { + if err := page.Close(); err != nil { + return err + } + } + id, path, err := d.extensionWebViewLocation(identity) + if err != nil { + return err + } + if provider := d.cfg.WebViewProvider; provider != nil { + remover, ok := provider.(enginewebview.ProfileRemover) + if !ok { + // An unavailable backend cannot have created a profile this run. + // Still fail if a persisted native profile directory exists. + _, statErr := os.Stat(path) + if provider.IsAvailable() || len(pages) != 0 || !os.IsNotExist(statErr) { + return fmt.Errorf("WebView provider does not support profile removal") + } + } else if err := remover.RemoveProfile(id, path); err != nil { + return err + } + } + return os.RemoveAll(path) +} diff --git a/pkg/download/extension_webview_config_test.go b/pkg/download/extension_webview_config_test.go new file mode 100644 index 00000000..2c972970 --- /dev/null +++ b/pkg/download/extension_webview_config_test.go @@ -0,0 +1,51 @@ +package download + +import ( + "path/filepath" + "sync" + "testing" + + "github.com/GopeedLab/gopeed/pkg/base" + enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" +) + +type profileCaptureProvider struct{ options []enginewebview.OpenOptions } + +func (*profileCaptureProvider) IsAvailable() bool { return true } +func (p *profileCaptureProvider) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) { + p.options = append(p.options, opts) + return fakeRuntimeWebViewPage{}, nil +} + +func TestWebViewProfileBoundToExtension(t *testing.T) { + root := t.TempDir() + d := &Downloader{cfg: &DownloaderConfig{StorageDir: root, DownloaderStoreConfig: &base.DownloaderStoreConfig{}}, configLock: &sync.RWMutex{}} + t.Cleanup(func() { + if d.webviewProxy != nil { + d.webviewProxy.Close() + } + }) + provider := &profileCaptureProvider{} + for _, id := range []string{"extension-a", "extension-b", "extension-a", "../../escape"} { + opener := &extensionWebViewOpener{downloader: d, provider: provider, identity: id} + _, err := opener.Open(enginewebview.OpenOptions{ProfileID: "forged", DataPath: "/tmp/forged", ProxyURL: "http://wrong:80"}) + if err != nil { + t.Fatal(err) + } + } + a, b, reopen := provider.options[0], provider.options[1], provider.options[2] + if a.ProfileID == b.ProfileID || a.DataPath == b.DataPath { + t.Fatal("different extensions shared browser storage") + } + if a.ProfileID != reopen.ProfileID || a.DataPath != reopen.DataPath { + t.Fatal("same extension lost profile identity") + } + for _, opts := range provider.options { + if filepath.Dir(opts.DataPath) != filepath.Join(root, "webview") { + t.Fatalf("profile escaped storage root: %s", opts.DataPath) + } + if opts.ProxyURL != d.webviewProxy.URL() && opts.ProxyURL != d.webviewProxy.SOCKSURL() { + t.Fatal("host proxy was overridden") + } + } +} diff --git a/pkg/download/extension_webview_remove_test.go b/pkg/download/extension_webview_remove_test.go new file mode 100644 index 00000000..50bc3191 --- /dev/null +++ b/pkg/download/extension_webview_remove_test.go @@ -0,0 +1,189 @@ +package download + +import ( + "errors" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/GopeedLab/gopeed/pkg/base" + webview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" +) + +type removalProvider struct { + pages map[string][]*removalPage + removed []string + failure error + entered chan struct{} + resume chan struct{} +} +type removalPage struct { + fakeRuntimeWebViewPage + closed bool +} + +func (p *removalPage) Close() error { p.closed = true; return nil } +func (p *removalProvider) IsAvailable() bool { return true } +func (p *removalProvider) Open(opts webview.OpenOptions) (webview.Page, error) { + if p.entered != nil { + close(p.entered) + <-p.resume + } + if p.pages == nil { + p.pages = make(map[string][]*removalPage) + } + page := &removalPage{} + p.pages[opts.ProfileID] = append(p.pages[opts.ProfileID], page) + return page, nil +} +func (p *removalProvider) RemoveProfile(id, path string) error { + for _, page := range p.pages[id] { + if !page.closed { + return errors.New("profile removed before closing pages") + } + } + if p.failure != nil { + return p.failure + } + p.removed = append(p.removed, id) + return nil +} +func removalDownloader(t *testing.T, provider *removalProvider) *Downloader { + t.Helper() + storage := NewMemStorage() + if err := storage.Setup([]string{bucketExtension, bucketExtensionStorage}); err != nil { + t.Fatal(err) + } + d := &Downloader{cfg: &DownloaderConfig{StorageDir: t.TempDir(), DownloaderStoreConfig: &base.DownloaderStoreConfig{}, WebViewProvider: provider}, storage: storage, configLock: &sync.RWMutex{}} + t.Cleanup(func() { + if d.webviewProxy != nil { + d.webviewProxy.Close() + } + }) + return d +} +func profileSentinel(t *testing.T, d *Downloader, id string) string { + t.Helper() + _, path, err := d.extensionWebViewLocation(id) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + sentinel := filepath.Join(path, "cookies.sqlite") + if err := os.WriteFile(sentinel, []byte("private browser state"), 0600); err != nil { + t.Fatal(err) + } + return sentinel +} +func TestDeleteExtensionRemovesWebViewData(t *testing.T) { + provider := &removalProvider{} + d := removalDownloader(t, provider) + ext, err := d.InstallExtensionByFolder("testdata/extensions/basic", true) + if err != nil { + t.Fatal(err) + } + identity := ext.Identity + opener := &extensionWebViewOpener{downloader: d, provider: provider, identity: identity, profile: d.extensionWebViewProfile(identity)} + for i := 0; i < 2; i++ { + if _, err := opener.Open(webview.OpenOptions{}); err != nil { + t.Fatal(err) + } + } + sentinel := profileSentinel(t, d, identity) + other := profileSentinel(t, d, "other-extension") + // Updating the installation keeps both its profile object and stored data. + if _, err := d.InstallExtensionByFolder("testdata/extensions/basic", true); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(sentinel); err != nil { + t.Fatal("upgrade removed browser data", err) + } + if err := d.DeleteExtension(identity); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(sentinel); !os.IsNotExist(err) { + t.Fatalf("browser data retained: %v", err) + } + if _, err := os.Stat(other); err != nil { + t.Fatal("other extension data was removed", err) + } + if _, err := os.Stat(ext.DevPath); err != nil { + t.Fatal("dev source was removed", err) + } + if len(provider.removed) != 1 { + t.Fatal("native profile was not removed") + } + if _, err := opener.Open(webview.OpenOptions{}); err == nil { + t.Fatal("old runtime recreated removed profile") + } + if _, err := d.InstallExtensionByFolder("testdata/extensions/basic", true); err != nil { + t.Fatal(err) + } + fresh := &extensionWebViewOpener{downloader: d, provider: provider, identity: identity, profile: d.extensionWebViewProfile(identity)} + if _, err := fresh.Open(webview.OpenOptions{}); err != nil { + t.Fatal("reinstall could not open profile", err) + } + if _, err := opener.Open(webview.OpenOptions{}); err == nil { + t.Fatal("reinstall reactivated old runtime") + } +} +func TestDeleteExtensionProfileFailureIsRetryable(t *testing.T) { + provider := &removalProvider{failure: errors.New("native store busy")} + d := removalDownloader(t, provider) + ext, err := d.InstallExtensionByFolder("testdata/extensions/basic", true) + if err != nil { + t.Fatal(err) + } + sentinel := profileSentinel(t, d, ext.Identity) + if err := d.DeleteExtension(ext.Identity); err == nil { + t.Fatal("cleanup failure was swallowed") + } + if _, err := d.GetExtension(ext.Identity); err != nil { + t.Fatal("extension removed before cleanup completed") + } + if _, err := os.Stat(sentinel); err != nil { + t.Fatal("profile path removed before native cleanup completed") + } + provider.failure = nil + if err := d.DeleteExtension(ext.Identity); err != nil { + t.Fatal(err) + } + if _, err := d.GetExtension(ext.Identity); !errors.Is(err, ErrExtensionNotFound) { + t.Fatal("extension was not uninstalled") + } +} +func TestRemoveProfileWaitsForOpeningPage(t *testing.T) { + provider := &removalProvider{entered: make(chan struct{}), resume: make(chan struct{})} + d := removalDownloader(t, provider) + opener := &extensionWebViewOpener{downloader: d, provider: provider, identity: "race", profile: d.extensionWebViewProfile("race")} + opened := make(chan error, 1) + go func() { _, err := opener.Open(webview.OpenOptions{}); opened <- err }() + <-provider.entered + removed := make(chan error, 1) + go func() { removed <- d.removeExtensionWebViewProfile("race") }() + close(provider.resume) + if err := <-opened; err != nil { + t.Fatal(err) + } + if err := <-removed; err != nil { + t.Fatal(err) + } + if len(provider.removed) != 1 { + t.Fatal("profile not removed") + } +} + +func TestUninstallWithoutAvailableWebView(t *testing.T) { + d := removalDownloader(t, &removalProvider{}) + d.cfg.WebViewProvider = webview.NewUnavailableProvider() + ext, err := d.InstallExtensionByFolder("testdata/extensions/basic", true) + if err != nil { + t.Fatal(err) + } + if err := d.DeleteExtension(ext.Identity); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/download/hls_live_test.go b/pkg/download/hls_live_test.go new file mode 100644 index 00000000..7fb41054 --- /dev/null +++ b/pkg/download/hls_live_test.go @@ -0,0 +1,97 @@ +package download + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/GopeedLab/gopeed/pkg/base" +) + +// TestDownloader_HlsLiveSite is a manual acceptance gate against a real site. +// It is skipped unless HLS_LIVE_URL is set, e.g.: +// +// HLS_LIVE_URL="https://host/path/index.m3u8" go test ./pkg/download/ -run TestDownloader_HlsLiveSite -v +func TestDownloader_HlsLiveSite(t *testing.T) { + playlistURL := os.Getenv("HLS_LIVE_URL") + if playlistURL == "" { + t.Skip("set HLS_LIVE_URL to run the live-site acceptance test") + } + referer := os.Getenv("HLS_LIVE_REFERER") + + downloader := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir()}) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + downloadDir := t.TempDir() + cfg, err := downloader.GetConfig() + if err != nil { + t.Fatal(err) + } + cfg.DownloadDir = downloadDir + if err := downloader.PutConfig(cfg); err != nil { + t.Fatal(err) + } + + req := &base.Request{URL: playlistURL} + if referer != "" { + req.Extra = map[string]any{"header": map[string]string{"Referer": referer}} + } + rr, err := downloader.Resolve(req, nil) + if err != nil { + t.Fatalf("resolve: %v", err) + } + name := rr.Res.Files[0].Name + size := rr.Res.Size + t.Logf("resolved: name=%s size=%d", name, size) + + taskID, err := downloader.Create(rr.ID) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(30 * time.Minute) + var lastLogged int64 + for { + task := downloader.GetTask(taskID) + if task == nil { + t.Fatal("task not found") + } + if task.Status == base.DownloadStatusDone { + break + } + if task.Status == base.DownloadStatusError { + t.Fatalf("task entered error state after %d bytes", task.Progress.Downloaded) + } + if time.Now().After(deadline) { + t.Fatalf("timeout, downloaded %d bytes", task.Progress.Downloaded) + } + if tick := task.Progress.Downloaded; tick-lastLogged > 10<<20 { + lastLogged = tick + t.Logf("progress: %d bytes", tick) + } + time.Sleep(500 * time.Millisecond) + } + + data, err := os.ReadFile(filepath.Join(downloadDir, name)) + if err != nil { + t.Fatal(err) + } + if int64(len(data)) == 0 { + t.Fatal("merged file is empty") + } + t.Logf("merged: %s (%d bytes)", name, len(data)) + if filepath.Ext(name) == ".ts" { + // MPEG-TS packets are 188 bytes and start with the 0x47 sync byte. + for i := 0; i < 188*4 && i < len(data); i += 188 { + if data[i] != 0x47 { + t.Fatalf("invalid TS sync byte at offset %d: %#x", i, data[i]) + } + } + } else if string(data[4:8]) != "ftyp" { + t.Fatalf("invalid fMP4 header: %q", data[:8]) + } + fmt.Printf("LIVE ACCEPTANCE OK: %s (%d bytes)\n", filepath.Join(downloadDir, name), len(data)) +} diff --git a/pkg/download/hls_protocol_test.go b/pkg/download/hls_protocol_test.go new file mode 100644 index 00000000..8ef48df8 --- /dev/null +++ b/pkg/download/hls_protocol_test.go @@ -0,0 +1,172 @@ +package download + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/GopeedLab/gopeed/pkg/base" +) + +// hlsTestCdn spins up a master-playlist HLS site whose segments require a +// Referer header (anti-hotlink) and returns the server plus expected bytes. +func hlsTestCdn(t *testing.T, segmentDelay time.Duration) (*httptest.Server, []byte) { + t.Helper() + const total = 6 + segments := make(map[int][]byte) + for i := 0; i < total; i++ { + segments[i] = []byte(fmt.Sprintf("ENGINE-SEGMENT-%02d", i)) + } + var want []byte + for i := 0; i < total; i++ { + want = append(want, segments[i]...) + } + + mux := http.NewServeMux() + mux.HandleFunc("/master.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("#EXTM3U\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=500000\nlow/index.m3u8\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=2000000\nhigh/index.m3u8\n")) + }) + mux.HandleFunc("/low/index.m3u8", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("#EXTM3U\n#EXTINF:4,\nseg-0.ts\n#EXT-X-ENDLIST\n")) + }) + mux.HandleFunc("/high/index.m3u8", func(w http.ResponseWriter, r *http.Request) { + var b strings.Builder + b.WriteString("#EXTM3U\n") + for i := 0; i < total; i++ { + b.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i)) + } + b.WriteString("#EXT-X-ENDLIST\n") + w.Write([]byte(b.String())) + }) + for i := 0; i < total; i++ { + idx := i + mux.HandleFunc(fmt.Sprintf("/high/seg-%d.ts", i), func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Referer") == "" { + w.WriteHeader(http.StatusForbidden) + return + } + if segmentDelay > 0 { + time.Sleep(segmentDelay) + } + w.Write(segments[idx]) + }) + } + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server, want +} + +func hlsResolveReferer() *base.Request { + return &base.Request{ + URL: "", + Extra: map[string]any{"header": map[string]string{"Referer": "https://player.example.com/page"}}, + } +} + +func TestDownloader_HlsProtocolEndToEnd(t *testing.T) { + server, want := hlsTestCdn(t, 0) + downloader := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir()}) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + downloadDir := t.TempDir() + cfg, err := downloader.GetConfig() + if err != nil { + t.Fatal(err) + } + cfg.DownloadDir = downloadDir + if err := downloader.PutConfig(cfg); err != nil { + t.Fatal(err) + } + + req := hlsResolveReferer() + req.URL = server.URL + "/master.m3u8" + rr, err := downloader.Resolve(req, nil) + if err != nil { + t.Fatal(err) + } + if got := rr.Res.Files[0].Name; got != "index.ts" { + t.Errorf("resolved name = %s, want index.ts", got) + } + taskID, err := downloader.Create(rr.ID) + if err != nil { + t.Fatal(err) + } + waitForTaskStatus(t, downloader, taskID, base.DownloadStatusDone, 10*time.Second) + + task := downloader.GetTask(taskID) + if task.Protocol != "hls" { + t.Errorf("task protocol = %s, want hls", task.Protocol) + } + data, err := os.ReadFile(filepath.Join(downloadDir, "index.ts")) + if err != nil { + t.Fatal(err) + } + if string(data) != string(want) { + t.Errorf("merged file mismatch: %q", data) + } +} + +func TestDownloader_HlsProtocolPauseContinue(t *testing.T) { + server, want := hlsTestCdn(t, 120*time.Millisecond) + downloader := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir()}) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + downloadDir := t.TempDir() + cfg, err := downloader.GetConfig() + if err != nil { + t.Fatal(err) + } + cfg.DownloadDir = downloadDir + // Throttle the protocol so the pause lands mid-download. + cfg.ProtocolConfig["hls"] = map[string]any{ + "segmentConnections": 2, + "maxRetries": 3, + "timeoutSeconds": 5, + "prefetchContentLength": false, + } + if err := downloader.PutConfig(cfg); err != nil { + t.Fatal(err) + } + + req := hlsResolveReferer() + req.URL = server.URL + "/master.m3u8" + req.Extra = map[string]any{"header": map[string]string{"Referer": "https://player.example.com/page"}} + // CreateDirect: engine resolves lazily on start, like a direct task. + taskID, err := downloader.CreateDirect(req, &base.Options{Name: "movie.ts"}) + if err != nil { + t.Fatal(err) + } + waitForTaskStatus(t, downloader, taskID, base.DownloadStatusRunning, 10*time.Second) + if err := downloader.Pause(&TaskFilter{IDs: []string{taskID}}); err != nil { + t.Fatal(err) + } + waitForTaskStatus(t, downloader, taskID, base.DownloadStatusPause, 5*time.Second) + + if err := downloader.Continue(&TaskFilter{IDs: []string{taskID}}); err != nil { + t.Fatal(err) + } + waitForTaskStatus(t, downloader, taskID, base.DownloadStatusDone, 30*time.Second) + + task := downloader.GetTask(taskID) + if task.Meta.Res.Size != int64(len(want)) { + t.Errorf("final size = %d, want %d", task.Meta.Res.Size, len(want)) + } + data, err := os.ReadFile(filepath.Join(downloadDir, "movie.ts")) + if err != nil { + t.Fatal(err) + } + if string(data) != string(want) { + t.Errorf("merged file mismatch after pause/continue: %q", data) + } +} diff --git a/pkg/download/model.go b/pkg/download/model.go index 0705827b..1a3b7ae8 100644 --- a/pkg/download/model.go +++ b/pkg/download/model.go @@ -9,6 +9,7 @@ import ( "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/internal/protocol/bt" "github.com/GopeedLab/gopeed/internal/protocol/ed2k" + "github.com/GopeedLab/gopeed/internal/protocol/hls" "github.com/GopeedLab/gopeed/internal/protocol/http" "github.com/GopeedLab/gopeed/pkg/base" enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" @@ -197,6 +198,7 @@ func (cfg *DownloaderConfig) Init() *DownloaderConfig { } if len(cfg.FetchManagers) == 0 { cfg.FetchManagers = []fetcher.FetcherManager{ + new(hls.FetcherManager), new(http.FetcherManager), new(bt.FetcherManager), new(ed2k.FetcherManager), diff --git a/pkg/rest/invoke.go b/pkg/rest/invoke.go index 122d20c3..90ef4f36 100644 --- a/pkg/rest/invoke.go +++ b/pkg/rest/invoke.go @@ -13,10 +13,13 @@ const ( TaskEventError = uint64(goapi.TaskEventError) ) -// Invoke dispatches a Core API request without using an HTTP listener. It is -// the shared entry point for Desktop FFI and gomobile bindings. -func Invoke(method, path, rawQuery, body string) string { +// Dispatch executes one in-process API request. Native scheduling and callback +// behavior belong to bind/native; this function only bridges into the shared +// API Service without opening an HTTP listener. +func Dispatch(method, path, rawQuery, body string) string { + runtimeMu.RLock() service := APIService + runtimeMu.RUnlock() if service == nil { return marshalInvokeResult(model.NewErrorResult("service not started")) } @@ -29,6 +32,8 @@ func Invoke(method, path, rawQuery, body string) string { } func SubscribeTaskEvents(mask uint64, listener func(payload string)) { + runtimeMu.RLock() + defer runtimeMu.RUnlock() if APIService == nil { return } diff --git a/pkg/rest/invoke_test.go b/pkg/rest/invoke_test.go index 1d9dfe2e..5981ef02 100644 --- a/pkg/rest/invoke_test.go +++ b/pkg/rest/invoke_test.go @@ -4,9 +4,12 @@ import ( "encoding/json" "fmt" "net/http" + "net/http/httptest" "path/filepath" "strings" + "sync" "testing" + "time" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/rest/model" @@ -27,7 +30,7 @@ func TestNativeInvokeWithoutRESTListener(t *testing.T) { } var result model.Result[json.RawMessage] - if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil { + if err := json.Unmarshal([]byte(Dispatch(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil { t.Fatal(err) } if result.Code != model.CodeOk { @@ -35,6 +38,75 @@ func TestNativeInvokeWithoutRESTListener(t *testing.T) { } } +func TestStartAPIServerIsNotBlockedByPendingDispatch(t *testing.T) { + requestStarted := make(chan struct{}) + releaseRequest := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseRequest) }) } + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + close(requestStarted) + <-releaseRequest + w.Header().Set("Content-Length", "0") + })) + t.Cleanup(func() { + release() + upstream.Close() + Stop() + }) + + if _, err := Start(&model.StartConfig{NativeMode: true, Storage: model.StorageMem}); err != nil { + t.Fatal(err) + } + requestBody, err := json.Marshal(&model.ResolveTask{Req: &base.Request{URL: upstream.URL}}) + if err != nil { + t.Fatal(err) + } + dispatchDone := make(chan struct{}) + go func() { + defer close(dispatchDone) + Dispatch(http.MethodPost, "/api/v1/resolve", "", string(requestBody)) + }() + select { + case <-requestStarted: + case <-time.After(time.Second): + t.Fatal("resolve request did not start") + } + + config, err := Downloader.GetConfig() + if err != nil { + t.Fatal(err) + } + config.API = &base.APIServerConfig{Enable: true, Network: "tcp", Address: "127.0.0.1:0"} + if err := Downloader.PutConfig(config); err != nil { + t.Fatal(err) + } + started := make(chan *model.APIServerState, 1) + startErrors := make(chan error, 1) + go func() { + state, err := StartAPIServer() + started <- state + startErrors <- err + }() + select { + case state := <-started: + if err := <-startErrors; err != nil { + t.Fatalf("start API server failed: %v", err) + } + if !state.Running || state.RunningPort == 0 { + t.Fatalf("API server did not start: %#v", state) + } + case <-time.After(time.Second): + t.Fatal("pending Resolve blocked API server startup") + } + + release() + select { + case <-dispatchDone: + case <-time.After(time.Second): + t.Fatal("resolve request did not finish after release") + } +} + func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T) { port, err := Start(&model.StartConfig{NativeMode: true, Storage: model.StorageMem}) if err != nil { @@ -106,7 +178,7 @@ func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T } var result model.Result[json.RawMessage] - if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil { + if err := json.Unmarshal([]byte(Dispatch(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil { t.Fatal(err) } if result.Code != model.CodeOk { @@ -153,7 +225,7 @@ func TestRestartAPIServerFailsFastAndLeavesListenerStopped(t *testing.T) { } var result model.Result[json.RawMessage] - if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil { + if err := json.Unmarshal([]byte(Dispatch(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil { t.Fatal(err) } if result.Code != model.CodeOk { @@ -229,7 +301,7 @@ func TestNativeAPIServerAutoStartFailureKeepsCoreAvailable(t *testing.T) { } var result model.Result[json.RawMessage] - if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil { + if err := json.Unmarshal([]byte(Dispatch(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil { t.Fatal(err) } if result.Code != model.CodeOk { diff --git a/pkg/rest/server.go b/pkg/rest/server.go index a62562ba..69cbf2c8 100644 --- a/pkg/rest/server.go +++ b/pkg/rest/server.go @@ -29,7 +29,7 @@ import ( ) var ( - runtimeMu sync.Mutex + runtimeMu sync.RWMutex Downloader *download.Downloader APIService *goapi.Service diff --git a/.agent/skills/gopeed-file-category-icons/.gitignore b/ui/flutter/.agent/skills/gopeed-file-category-icons/.gitignore similarity index 100% rename from .agent/skills/gopeed-file-category-icons/.gitignore rename to ui/flutter/.agent/skills/gopeed-file-category-icons/.gitignore diff --git a/.agent/skills/gopeed-file-category-icons/SKILL.md b/ui/flutter/.agent/skills/gopeed-file-category-icons/SKILL.md similarity index 94% rename from .agent/skills/gopeed-file-category-icons/SKILL.md rename to ui/flutter/.agent/skills/gopeed-file-category-icons/SKILL.md index 02bf3027..f4fbaf9a 100644 --- a/.agent/skills/gopeed-file-category-icons/SKILL.md +++ b/ui/flutter/.agent/skills/gopeed-file-category-icons/SKILL.md @@ -7,7 +7,7 @@ description: Design or revise Gopeed file-classification icons as one cohesive S Maintain Gopeed's file-classification visual language and its deterministic SVG → TTF → Flutter `IconData` pipeline. -The canonical editable materials live in `design/file-category-icons/`, outside the Flutter project. Read its `README.md` before changing any source. Do not add that directory to Flutter assets. +The canonical editable materials live in `design/file-category-icons/` as tooling-only sources inside the Flutter project. Read its `README.md` before changing any source. Do not add that directory to Flutter assets. ## Classification grammar @@ -49,7 +49,7 @@ Use [assets/manifest.example.json](assets/manifest.example.json) and [assets/fol ## Invariants - Existing codepoints are API: never renumber or reuse one unless the user explicitly approves a breaking migration. -- `design/file-category-icons/` is the sole editable source of truth. Flutter keeps only the generated runtime TTF and Dart constants. +- `design/file-category-icons/` is the sole editable source of truth. The packaged Flutter runtime uses only the generated TTF and Dart constants. - Never add the design-material directory to `pubspec.yaml`; it must remain outside the packaged Flutter asset graph. - The manifest is the source of truth. Do not hand-edit generated Dart constants or patch the binary font. - Keep icon names lowercase `snake_case`; the generator emits lowerCamelCase Dart fields. diff --git a/.agent/skills/gopeed-file-category-icons/agents/openai.yaml b/ui/flutter/.agent/skills/gopeed-file-category-icons/agents/openai.yaml similarity index 100% rename from .agent/skills/gopeed-file-category-icons/agents/openai.yaml rename to ui/flutter/.agent/skills/gopeed-file-category-icons/agents/openai.yaml diff --git a/.agent/skills/gopeed-file-category-icons/assets/folder-symbol-template.svg b/ui/flutter/.agent/skills/gopeed-file-category-icons/assets/folder-symbol-template.svg similarity index 100% rename from .agent/skills/gopeed-file-category-icons/assets/folder-symbol-template.svg rename to ui/flutter/.agent/skills/gopeed-file-category-icons/assets/folder-symbol-template.svg diff --git a/.agent/skills/gopeed-file-category-icons/assets/manifest.example.json b/ui/flutter/.agent/skills/gopeed-file-category-icons/assets/manifest.example.json similarity index 100% rename from .agent/skills/gopeed-file-category-icons/assets/manifest.example.json rename to ui/flutter/.agent/skills/gopeed-file-category-icons/assets/manifest.example.json diff --git a/.agent/skills/gopeed-file-category-icons/references/style-guide.md b/ui/flutter/.agent/skills/gopeed-file-category-icons/references/style-guide.md similarity index 100% rename from .agent/skills/gopeed-file-category-icons/references/style-guide.md rename to ui/flutter/.agent/skills/gopeed-file-category-icons/references/style-guide.md diff --git a/.agent/skills/gopeed-file-category-icons/scripts/build_icon_font.py b/ui/flutter/.agent/skills/gopeed-file-category-icons/scripts/build_icon_font.py similarity index 100% rename from .agent/skills/gopeed-file-category-icons/scripts/build_icon_font.py rename to ui/flutter/.agent/skills/gopeed-file-category-icons/scripts/build_icon_font.py diff --git a/.agent/skills/gopeed-file-category-icons/scripts/preview_icon_font.py b/ui/flutter/.agent/skills/gopeed-file-category-icons/scripts/preview_icon_font.py similarity index 100% rename from .agent/skills/gopeed-file-category-icons/scripts/preview_icon_font.py rename to ui/flutter/.agent/skills/gopeed-file-category-icons/scripts/preview_icon_font.py diff --git a/.agent/skills/gopeed-file-category-icons/scripts/requirements.txt b/ui/flutter/.agent/skills/gopeed-file-category-icons/scripts/requirements.txt similarity index 100% rename from .agent/skills/gopeed-file-category-icons/scripts/requirements.txt rename to ui/flutter/.agent/skills/gopeed-file-category-icons/scripts/requirements.txt diff --git a/ui/flutter/AGENTS.md b/ui/flutter/AGENTS.md index 484e28a0..f827385c 100644 --- a/ui/flutter/AGENTS.md +++ b/ui/flutter/AGENTS.md @@ -109,6 +109,18 @@ Guidelines: - forbidden Material product-component usage - If a screen already uses `shadcn_flutter`, do not mix in Material product widgets for convenience. Replace them with `shadcn_flutter` equivalents or shared wrappers built on top of `shadcn_flutter`. +### 3.3.1 Text Input Rules + +- All product-facing editable text controls must use the shared `AppTextField` from `lib/shared/widgets/app_text_field.dart`. +- This rule applies to every input variant, including search fields, login and password fields, URL fields, numeric fields, multiline editors, settings controls, dialog inputs, and feature-specific form fields. +- Do not instantiate Flutter Material `TextField` or `shadcn_flutter`'s `TextField` directly in feature or page code. Direct construction is allowed only while implementing or extending a shared input primitive, with the reason documented in that primitive. +- `AppTextField` owns the cross-platform text-selection menu policy. Android, iOS, desktop, and Web must all use Flutter's adaptive native context menu so long-press and right-click selection actions follow platform behavior without `shadcn_flutter`'s full-screen context-menu overlay. +- Do not use `shadcn_flutter`'s editable-text context menu on any platform. +- Feature and page code must not override `contextMenuBuilder`. Any exception must be implemented centrally in `AppTextField`, justified by a platform requirement, and covered by Android/iOS and desktop tests. +- Preserve existing field behavior when migrating to `AppTextField`, including controllers, focus nodes, input formatters, autofill hints, password visibility, read-only state, input features, keyboard actions, and multiline sizing. +- Prefer stable keys or `AppTextField` when locating app inputs in widget tests. Do not couple product tests to the exact runtime type of the underlying `shadcn_flutter` implementation. +- Changes to the shared input policy must include regression coverage for Android long-press behavior and desktop right-click behavior. Web builds must keep the same shared native-menu policy. + ## 3.4) Desktop Design Metrics Use the following desktop reference metrics unless a specific screen requires a documented exception: diff --git a/ui/flutter/analysis_options.yaml b/ui/flutter/analysis_options.yaml index 89f7f921..a1a8bbfc 100644 --- a/ui/flutter/analysis_options.yaml +++ b/ui/flutter/analysis_options.yaml @@ -29,3 +29,7 @@ linter: formatter: page_width: 120 + +analyzer: + exclude: + - third_party/** diff --git a/ui/flutter/android/app/src/main/AndroidManifest.xml b/ui/flutter/android/app/src/main/AndroidManifest.xml index 69e93226..968c7987 100644 --- a/ui/flutter/android/app/src/main/AndroidManifest.xml +++ b/ui/flutter/android/app/src/main/AndroidManifest.xml @@ -32,6 +32,12 @@ android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" /> + + diff --git a/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt b/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt index 165070da..47b0e3b9 100644 --- a/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt +++ b/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt @@ -1,6 +1,7 @@ package com.gopeed.gopeed import com.gopeed.libgopeed.Libgopeed +import com.gopeed.libgopeed.InvokeResultListener import com.gopeed.libgopeed.TaskEventListener import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine @@ -38,13 +39,23 @@ class MainActivity : FlutterActivity() { "restartApiServer" -> result.success(Libgopeed.restartAPIServer()) "invoke" -> { try { - result.success( - Libgopeed.invoke( - call.argument("method") ?: "", - call.argument("path") ?: "", - call.argument("query") ?: "", - call.argument("body") ?: "", - ), + Libgopeed.invokeAsync( + call.argument("method") ?: "", + call.argument("path") ?: "", + call.argument("query") ?: "", + call.argument("body") ?: "", + call.argument("requestID")?.toLong() ?: 0L, + object : InvokeResultListener { + override fun onResult(requestID: Long, success: Boolean, payload: String?) { + runOnUiThread { + if (success) { + result.success(payload ?: "") + } else { + result.error("ERROR", payload ?: "InvokeAsync failed", null) + } + } + } + }, ) } catch (error: Exception) { result.error("ERROR", error.message, null) diff --git a/design/file-category-icons/README.md b/ui/flutter/design/file-category-icons/README.md similarity index 75% rename from design/file-category-icons/README.md rename to ui/flutter/design/file-category-icons/README.md index 821a8513..3f806947 100644 --- a/design/file-category-icons/README.md +++ b/ui/flutter/design/file-category-icons/README.md @@ -1,6 +1,6 @@ # 文件分类图标源物料 -此目录是 Gopeed 文件分类图标的唯一源物料目录,位于 Flutter 工程之外,不得加入 `ui/flutter/pubspec.yaml` 的 `assets` 配置。 +此目录是 Gopeed 文件分类图标的唯一源物料目录。它位于 Flutter 工程内,但只用于设计和生成流程,不得加入 `pubspec.yaml` 的 `assets` 配置。 ## 维护限制 @@ -17,8 +17,8 @@ ## 生成产物 -- Flutter 字体:`ui/flutter/assets/fonts/GopeedIcons.ttf` -- Flutter 常量:`ui/flutter/lib/core/icons/gopeed_icons.dart` +- Flutter 字体:`assets/fonts/GopeedIcons.ttf` +- Flutter 常量:`lib/core/icons/gopeed_icons.dart` 所有修改都应遵循: diff --git a/design/file-category-icons/manifest.json b/ui/flutter/design/file-category-icons/manifest.json similarity index 95% rename from design/file-category-icons/manifest.json rename to ui/flutter/design/file-category-icons/manifest.json index 50c95c9f..60457fd8 100644 --- a/design/file-category-icons/manifest.json +++ b/ui/flutter/design/file-category-icons/manifest.json @@ -1,7 +1,7 @@ { "fontFamily": "GopeedIcons", - "fontOutput": "../../ui/flutter/assets/fonts/GopeedIcons.ttf", - "dartOutput": "../../ui/flutter/lib/core/icons/gopeed_icons.dart", + "fontOutput": "../../assets/fonts/GopeedIcons.ttf", + "dartOutput": "../../lib/core/icons/gopeed_icons.dart", "previewOutput": "preview/gopeed_icons_preview.png", "dartClass": "GopeedIcons", "codepointStart": "0xE900", diff --git a/design/file-category-icons/preview/gopeed_icons_preview.png b/ui/flutter/design/file-category-icons/preview/gopeed_icons_preview.png similarity index 100% rename from design/file-category-icons/preview/gopeed_icons_preview.png rename to ui/flutter/design/file-category-icons/preview/gopeed_icons_preview.png diff --git a/design/file-category-icons/svg/file-android.svg b/ui/flutter/design/file-category-icons/svg/file-android.svg similarity index 100% rename from design/file-category-icons/svg/file-android.svg rename to ui/flutter/design/file-category-icons/svg/file-android.svg diff --git a/design/file-category-icons/svg/file-archive.svg b/ui/flutter/design/file-category-icons/svg/file-archive.svg similarity index 100% rename from design/file-category-icons/svg/file-archive.svg rename to ui/flutter/design/file-category-icons/svg/file-archive.svg diff --git a/design/file-category-icons/svg/file-audio.svg b/ui/flutter/design/file-category-icons/svg/file-audio.svg similarity index 100% rename from design/file-category-icons/svg/file-audio.svg rename to ui/flutter/design/file-category-icons/svg/file-audio.svg diff --git a/design/file-category-icons/svg/file-code.svg b/ui/flutter/design/file-category-icons/svg/file-code.svg similarity index 100% rename from design/file-category-icons/svg/file-code.svg rename to ui/flutter/design/file-category-icons/svg/file-code.svg diff --git a/design/file-category-icons/svg/file-database.svg b/ui/flutter/design/file-category-icons/svg/file-database.svg similarity index 100% rename from design/file-category-icons/svg/file-database.svg rename to ui/flutter/design/file-category-icons/svg/file-database.svg diff --git a/design/file-category-icons/svg/file-disk-image.svg b/ui/flutter/design/file-category-icons/svg/file-disk-image.svg similarity index 100% rename from design/file-category-icons/svg/file-disk-image.svg rename to ui/flutter/design/file-category-icons/svg/file-disk-image.svg diff --git a/design/file-category-icons/svg/file-document.svg b/ui/flutter/design/file-category-icons/svg/file-document.svg similarity index 100% rename from design/file-category-icons/svg/file-document.svg rename to ui/flutter/design/file-category-icons/svg/file-document.svg diff --git a/design/file-category-icons/svg/file-ebook.svg b/ui/flutter/design/file-category-icons/svg/file-ebook.svg similarity index 100% rename from design/file-category-icons/svg/file-ebook.svg rename to ui/flutter/design/file-category-icons/svg/file-ebook.svg diff --git a/design/file-category-icons/svg/file-font.svg b/ui/flutter/design/file-category-icons/svg/file-font.svg similarity index 100% rename from design/file-category-icons/svg/file-font.svg rename to ui/flutter/design/file-category-icons/svg/file-font.svg diff --git a/design/file-category-icons/svg/file-image.svg b/ui/flutter/design/file-category-icons/svg/file-image.svg similarity index 100% rename from design/file-category-icons/svg/file-image.svg rename to ui/flutter/design/file-category-icons/svg/file-image.svg diff --git a/design/file-category-icons/svg/file-installer.svg b/ui/flutter/design/file-category-icons/svg/file-installer.svg similarity index 100% rename from design/file-category-icons/svg/file-installer.svg rename to ui/flutter/design/file-category-icons/svg/file-installer.svg diff --git a/design/file-category-icons/svg/file-ios.svg b/ui/flutter/design/file-category-icons/svg/file-ios.svg similarity index 100% rename from design/file-category-icons/svg/file-ios.svg rename to ui/flutter/design/file-category-icons/svg/file-ios.svg diff --git a/design/file-category-icons/svg/file-pdf.svg b/ui/flutter/design/file-category-icons/svg/file-pdf.svg similarity index 100% rename from design/file-category-icons/svg/file-pdf.svg rename to ui/flutter/design/file-category-icons/svg/file-pdf.svg diff --git a/design/file-category-icons/svg/file-presentation.svg b/ui/flutter/design/file-category-icons/svg/file-presentation.svg similarity index 100% rename from design/file-category-icons/svg/file-presentation.svg rename to ui/flutter/design/file-category-icons/svg/file-presentation.svg diff --git a/design/file-category-icons/svg/file-spreadsheet.svg b/ui/flutter/design/file-category-icons/svg/file-spreadsheet.svg similarity index 100% rename from design/file-category-icons/svg/file-spreadsheet.svg rename to ui/flutter/design/file-category-icons/svg/file-spreadsheet.svg diff --git a/design/file-category-icons/svg/file-text.svg b/ui/flutter/design/file-category-icons/svg/file-text.svg similarity index 100% rename from design/file-category-icons/svg/file-text.svg rename to ui/flutter/design/file-category-icons/svg/file-text.svg diff --git a/design/file-category-icons/svg/file-video.svg b/ui/flutter/design/file-category-icons/svg/file-video.svg similarity index 100% rename from design/file-category-icons/svg/file-video.svg rename to ui/flutter/design/file-category-icons/svg/file-video.svg diff --git a/design/file-category-icons/svg/file-web.svg b/ui/flutter/design/file-category-icons/svg/file-web.svg similarity index 100% rename from design/file-category-icons/svg/file-web.svg rename to ui/flutter/design/file-category-icons/svg/file-web.svg diff --git a/design/file-category-icons/svg/file.svg b/ui/flutter/design/file-category-icons/svg/file.svg similarity index 100% rename from design/file-category-icons/svg/file.svg rename to ui/flutter/design/file-category-icons/svg/file.svg diff --git a/design/file-category-icons/svg/folder-bt.svg b/ui/flutter/design/file-category-icons/svg/folder-bt.svg similarity index 100% rename from design/file-category-icons/svg/folder-bt.svg rename to ui/flutter/design/file-category-icons/svg/folder-bt.svg diff --git a/design/file-category-icons/svg/folder.svg b/ui/flutter/design/file-category-icons/svg/folder.svg similarity index 100% rename from design/file-category-icons/svg/folder.svg rename to ui/flutter/design/file-category-icons/svg/folder.svg diff --git a/design/file-category-icons/svg/protocol-bt.svg b/ui/flutter/design/file-category-icons/svg/protocol-bt.svg similarity index 100% rename from design/file-category-icons/svg/protocol-bt.svg rename to ui/flutter/design/file-category-icons/svg/protocol-bt.svg diff --git a/design/file-category-icons/svg/protocol-ed2k.svg b/ui/flutter/design/file-category-icons/svg/protocol-ed2k.svg similarity index 100% rename from design/file-category-icons/svg/protocol-ed2k.svg rename to ui/flutter/design/file-category-icons/svg/protocol-ed2k.svg diff --git a/ui/flutter/docs/backend-transport-architecture.md b/ui/flutter/docs/backend-transport-architecture.md index 2de7ab81..a00985c1 100644 --- a/ui/flutter/docs/backend-transport-architecture.md +++ b/ui/flutter/docs/backend-transport-architecture.md @@ -259,7 +259,7 @@ GetAPIServerState StartAPIServer StopAPIServer RestartAPIServer -Invoke +InvokeAsync FreeCString SubscribeTaskEvents ``` @@ -268,9 +268,12 @@ Requirements: - Use the cgo C-string ABI in the first phase. Reconsider pointer-and-length parameters only during a future ABI-breaking revision. - Memory allocated by Go must be released with `FreeCString`. -- Dart-owned input memory must be released after the synchronous native call returns. -- `Invoke` returns the same Result JSON as REST; business errors do not use a separate ABI error model. -- Run blocking Desktop FFI work outside the Flutter UI isolate. Mobile MethodChannel handlers likewise run blocking Go work on a background task queue. +- `InvokeAsync` is the only native invocation entry point. Desktop and gomobile adapters both submit to the shared bounded-concurrency executor in `bind/native`. +- The shared executor uses `clamp(GOMAXPROCS * 2, 4, 32)` workers. Requests wait in FIFO order when every worker is busy; pool saturation is backpressure, not an API error. +- The Desktop adapter copies every Dart-owned input string before returning and completes through a `NativeCallable.listener` callback. +- Dart-owned input memory must be released after the non-blocking `InvokeAsync` native entry returns. Go-owned callback payloads remain valid until Dart copies them and calls `FreeCString`. +- `InvokeAsync` returns the same Result JSON as REST through its callback; business errors do not use a separate ABI error model. +- The Desktop bridge isolate owns the dynamic library and native callbacks, decodes callback JSON, and forwards sendable Dart values to the UI isolate. Mobile MethodChannel handlers run on background task queues, while invoke completion is delivered through the shared Go callback flow. - Dart bindings are generated from `include/libgopeed.h` with `ffigen`. Every exported Desktop symbol must be added to the `ffigen.functions.include` allowlist. - Never hand-edit `lib/core/ffi/libgopeed_bind.dart`; regenerate it with `flutter pub run ffigen` after changing the C header. @@ -285,14 +288,14 @@ GetAPIServerState() StartAPIServer() StopAPIServer() RestartAPIServer() -Invoke(method, path, query, body) +InvokeAsync(method, path, query, body, requestID, listener) SubscribeTaskEvents(mask, listener) ``` Flutter bridge rules: -- Normal calls use MethodChannel. -- Android performs normal calls on a background task queue. +- Normal calls use MethodChannel, while `InvokeAsync` completes the pending MethodChannel result through a gomobile listener. +- Android and iOS run the Libgopeed MethodChannel handler on a serial background task queue; callback adapters marshal completion back to the platform UI thread. - Events reuse the same MethodChannel through the reverse `taskEvent` message. - Kotlin and Swift contain no business logic. They only forward arguments, dispatch callbacks to the UI thread, and translate errors. @@ -589,8 +592,8 @@ Flutter converts all of them into `GopeedException`. Components do not handle Di ### 11.3 Platform smoke tests -- Windows, macOS, Linux: startup, Invoke, events, shutdown, and repeated startup. -- Android, iOS: gomobile Invoke, reverse MethodChannel events, and background/foreground transitions. +- Windows, macOS, Linux: startup, InvokeAsync, events, shutdown, and repeated startup. +- Android, iOS: gomobile InvokeAsync callbacks, reverse MethodChannel events, and background/foreground transitions. - Web: REST, login, proxy, and task-file downloads. - Native: enabling and disabling REST does not interrupt download tasks. @@ -605,7 +608,7 @@ Flutter converts all of them into `GopeedException`. Components do not handle Di ### Phase 2: Native in-process calls — implemented on this branch - Integrate the FFI Transport on Desktop. -- Extend gomobile Invoke and MethodChannel on Mobile. +- Extend gomobile InvokeAsync callbacks and MethodChannel on Mobile. - Move Flutter API calls to the unified Transport so each typed method is maintained once. - Do not create a socket listener by default on native clients. diff --git a/ui/flutter/include/libgopeed.h b/ui/flutter/include/libgopeed.h index a7656dd9..9eb93ac8 100644 --- a/ui/flutter/include/libgopeed.h +++ b/ui/flutter/include/libgopeed.h @@ -12,6 +12,8 @@ #ifndef GO_CGO_GOSTRING_TYPEDEF typedef struct { const char *p; ptrdiff_t n; } _GoString_; +extern size_t _GoStringLen(_GoString_ s); +extern const char *_GoStringPtr(_GoString_ s); #endif #endif @@ -19,6 +21,24 @@ typedef struct { const char *p; ptrdiff_t n; } _GoString_; /* Start of preamble from import "C" comments. */ +#line 3 "main.go" + +#include +#include + +typedef void (*TaskEventCallback)(char* payload); + +typedef void (*InvokeResultCallback)(uint64_t request_id, int success, char* payload); + +static void callTaskEventCallback(uintptr_t callback, char* payload) { + ((TaskEventCallback)callback)(payload); +} + +static void callInvokeResultCallback(uintptr_t callback, uint64_t request_id, int success, char* payload) { + ((InvokeResultCallback)callback)(request_id, success, payload); +} + +#line 1 "cgo-generated-wrapper" /* End of preamble from import "C" comments. */ @@ -44,10 +64,16 @@ typedef size_t GoUintptr; typedef float GoFloat32; typedef double GoFloat64; #ifdef _MSC_VER +#if !defined(__cplusplus) || _MSVC_LANG <= 201402L #include typedef _Fcomplex GoComplex64; typedef _Dcomplex GoComplex128; #else +#include +typedef std::complex GoComplex64; +typedef std::complex GoComplex128; +#endif +#else typedef float _Complex GoComplex64; typedef double _Complex GoComplex128; #endif @@ -81,13 +107,13 @@ struct Start_return { char* r1; }; extern struct Start_return Start(char* cfg); -extern void Stop(); -extern char* GetAPIServerState(); -extern char* StartAPIServer(); -extern char* StopAPIServer(); -extern char* RestartAPIServer(); -extern char* Invoke(char* method, char* path, char* query, char* body); -extern void SubscribeTaskEvents(GoUint64 mask, GoUintptr callback); +extern void Stop(void); +extern char* GetAPIServerState(void); +extern char* StartAPIServer(void); +extern char* StopAPIServer(void); +extern char* RestartAPIServer(void); +extern void InvokeAsync(char* method, char* path, char* query, char* body, long long unsigned int requestID, uintptr_t callback); +extern void SubscribeTaskEvents(long long unsigned int mask, uintptr_t callback); extern void FreeCString(char* value); #ifdef __cplusplus diff --git a/ui/flutter/ios/GopeedLiveActivity/GopeedLiveActivityWidget.swift b/ui/flutter/ios/GopeedLiveActivity/GopeedLiveActivityWidget.swift new file mode 100644 index 00000000..3d353cd9 --- /dev/null +++ b/ui/flutter/ios/GopeedLiveActivity/GopeedLiveActivityWidget.swift @@ -0,0 +1,236 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +@main +struct GopeedLiveActivityBundle: WidgetBundle { + var body: some Widget { + GopeedLiveActivityWidget() + } +} + +struct GopeedLiveActivityWidget: Widget { + + var body: some WidgetConfiguration { + + ActivityConfiguration( + for: GopeedDownloadAttributes.self + ) { context in + + // MARK: Lock Screen / notification banner + + VStack(spacing: 10) { + + HStack { + + Image(systemName: statusIcon(context.state.status)) + .font(.title3) + + VStack(alignment: .leading, spacing: 2) { + + Text(context.attributes.fileName) + .font(.headline) + .lineLimit(1) + + Text(statusText(context.state.status)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text( + "\(Int(context.state.progress * 100))%" + ) + .font(.headline) + .monospacedDigit() + } + + progressView(context) + + HStack { + + Text( + "\(formatBytes(context.state.downloaded)) / \(formatBytes(context.state.total))" + ) + + Spacer() + + if context.state.speed > 0 { + Text( + "\(formatBytes(context.state.speed))/s" + ) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .activityBackgroundTint(.black.opacity(0.85)) + .activitySystemActionForegroundColor(.white) + + } dynamicIsland: { context in + + DynamicIsland { + + // MARK: Expanded Dynamic Island + + DynamicIslandExpandedRegion(.leading) { + Image( + systemName: statusIcon( + context.state.status + ) + ) + .font(.title3) + } + + DynamicIslandExpandedRegion(.center) { + Text(context.attributes.fileName) + .lineLimit(1) + .font(.headline) + } + + DynamicIslandExpandedRegion(.trailing) { + Text( + "\(Int(context.state.progress * 100))%" + ) + .monospacedDigit() + .font(.headline) + } + + DynamicIslandExpandedRegion(.bottom) { + + VStack(spacing: 6) { + + progressView(context) + + HStack { + + Text( + "\(formatBytes(context.state.downloaded)) / \(formatBytes(context.state.total))" + ) + + Spacer() + + if context.state.speed > 0 { + Text( + "\(formatBytes(context.state.speed))/s" + ) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + } compactLeading: { + + Image( + systemName: statusIcon( + context.state.status + ) + ) + + } compactTrailing: { + + Text( + "\(Int(context.state.progress * 100))%" + ) + .monospacedDigit() + + } minimal: { + + Image(systemName: "arrow.down") + } + } + } + + + // MARK: - Progress bar + + @ViewBuilder + private func progressView( + _ context: + ActivityViewContext + ) -> some View { + + if context.state.usesEstimatedProgress, + context.state.estimatedEnd > + context.state.estimatedStart { + + ProgressView( + timerInterval: + context.state.estimatedStart + ... + context.state.estimatedEnd, + countsDown: false + ) + + } else { + + ProgressView( + value: context.state.progress, + total: 1.0 + ) + } + } + + + // MARK: - Formatting + + private func formatBytes( + _ bytes: Int64 + ) -> String { + + guard bytes > 0 else { + return "0 B" + } + + return ByteCountFormatter.string( + fromByteCount: bytes, + countStyle: .file + ) + } + + + private func statusIcon( + _ status: String + ) -> String { + + switch status { + + case "done": + return "checkmark.circle.fill" + + case "error": + return "exclamationmark.triangle.fill" + + case "pause", "paused": + return "pause.circle.fill" + + default: + return "arrow.down.circle.fill" + } + } + + + private func statusText( + _ status: String + ) -> String { + + switch status { + + case "done": + return "Download complete" + + case "error": + return "Download failed" + + case "pause", "paused": + return "Paused" + + default: + return "Downloading" + } + } +} diff --git a/ui/flutter/ios/GopeedLiveActivity/Info.plist b/ui/flutter/ios/GopeedLiveActivity/Info.plist new file mode 100644 index 00000000..3d6cefc8 --- /dev/null +++ b/ui/flutter/ios/GopeedLiveActivity/Info.plist @@ -0,0 +1,24 @@ + + + + + + + CFBundleDisplayName + Gopeed + + CFBundleShortVersionString + $(MARKETING_VERSION) + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + + diff --git a/ui/flutter/ios/Podfile b/ui/flutter/ios/Podfile index 8a9751f4..918eb16d 100644 --- a/ui/flutter/ios/Podfile +++ b/ui/flutter/ios/Podfile @@ -1,4 +1,4 @@ -platform :ios, '13.0' +platform :ios, '14.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ui/flutter/ios/Runner.xcodeproj/project.pbxproj b/ui/flutter/ios/Runner.xcodeproj/project.pbxproj index 0a84a65f..677ed142 100644 --- a/ui/flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/ui/flutter/ios/Runner.xcodeproj/project.pbxproj @@ -7,13 +7,19 @@ objects = { /* Begin PBXBuildFile section */ + 032447D610CFAF8A75642212 /* GopeedLiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */; }; 0C585CBA2D41E28900FF2EC0 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C585CB92D41E28900FF2EC0 /* ShareViewController.swift */; }; 0C585CBD2D41E28900FF2EC0 /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 0C585CBC2D41E28900FF2EC0 /* Base */; }; 0C585CC12D41E28900FF2EC0 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 1045ACE898D35C0D8D8A993E /* GopeedDownloadAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 1E29080079AAFD1CF2C71262 /* GopeedDownloadAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 3EF29839EA13551EB8144269 /* GopeedLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */; }; 6085D504396791024CEC496B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8823E31DA135C410E362683E /* Pods_RunnerTests.framework */; }; + 6243BE8F995F5284DBDD7DE3 /* GopeedLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -30,6 +36,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 03B416C78831516F6591BD24 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = FFA6D07C1B1CB56543DE4B67; + remoteInfo = GopeedLiveActivityExtension; + }; 0C585CBF2D41E28900FF2EC0 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -54,6 +67,7 @@ dstSubfolderSpec = 13; files = ( 0C585CC12D41E28900FF2EC0 /* ShareExtension.appex in Embed Foundation Extensions */, + 3EF29839EA13551EB8144269 /* GopeedLiveActivityExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -72,6 +86,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedDownloadAttributes.swift; sourceTree = ""; }; 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 0C585CB92D41E28900FF2EC0 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; 0C585CBC2D41E28900FF2EC0 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; @@ -85,11 +100,15 @@ 3217CFC346202B3E2A8C52DD /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedLiveActivityWidget.swift; sourceTree = ""; }; + 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedContinuedProcessingManager.swift; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 52A13633B52EDC9D3F479DFD /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 55356FCC7E573C3FEEC2DF4D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 5B918B8D5281BAE2DEA6BE3C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = GopeedLiveActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 78EE8538B1DF04DAF73E75D3 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = ""; }; 78F0D48FED63C04B9A3FFDE4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; @@ -112,6 +131,7 @@ D554135997A5C7A2C62E1B64 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; D081C25B294826C0006EB10B /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; }; D0E623A729482D030001185E /* Libgopeed.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Libgopeed.xcframework; path = Frameworks/Libgopeed.xcframework; sourceTree = ""; }; + EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedLiveActivityManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -123,6 +143,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2134C1710299C602C39EE9D6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -194,6 +221,16 @@ name = Frameworks; sourceTree = ""; }; + 5FB22DCA9F2EA11B7C1F1A22 /* GopeedLiveActivity */ = { + isa = PBXGroup; + children = ( + 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */, + 52A13633B52EDC9D3F479DFD /* Info.plist */, + ); + name = GopeedLiveActivity; + path = GopeedLiveActivity; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -215,6 +252,7 @@ 331C8082294A63A400263BE5 /* RunnerTests */, 2B6C6F082D376286EF3D5FA3 /* Pods */, 44BA5D7C75A3E4957F6560C7 /* Frameworks */, + 5FB22DCA9F2EA11B7C1F1A22 /* GopeedLiveActivity */, ); sourceTree = ""; }; @@ -224,6 +262,7 @@ 97C146EE1CF9000F007C117D /* Runner.app */, 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */, ); name = Products; sourceTree = ""; @@ -244,6 +283,9 @@ A20000000000000000000003 /* GopeedTaskEventForwarder.h */, A20000000000000000000002 /* GopeedTaskEventForwarder.m */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */, + EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */, + 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */, ); path = Runner; sourceTree = ""; @@ -306,12 +348,30 @@ ); dependencies = ( 0C585CC02D41E28900FF2EC0 /* PBXTargetDependency */, + FBEBBD3469D2B152FDD0B424 /* PBXTargetDependency */, ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + FFA6D07C1B1CB56543DE4B67 /* GopeedLiveActivityExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = DAF208923F589C87CBB49D56 /* Build configuration list for PBXNativeTarget "GopeedLiveActivityExtension" */; + buildPhases = ( + 1A1326FD8C4C86ED5A529666 /* Sources */, + 2134C1710299C602C39EE9D6 /* Frameworks */, + 9AB02E797D34C8878FDBED22 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = GopeedLiveActivityExtension; + productName = GopeedLiveActivityExtension; + productReference = 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -351,6 +411,7 @@ 97C146ED1CF9000F007C117D /* Runner */, 0C585CB62D41E28900FF2EC0 /* ShareExtension */, 331C8080294A63A400263BE5 /* RunnerTests */, + FFA6D07C1B1CB56543DE4B67 /* GopeedLiveActivityExtension */, ); }; /* End PBXProject section */ @@ -382,6 +443,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 9AB02E797D34C8878FDBED22 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -510,6 +578,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 1A1326FD8C4C86ED5A529666 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6243BE8F995F5284DBDD7DE3 /* GopeedLiveActivityWidget.swift in Sources */, + 1045ACE898D35C0D8D8A993E /* GopeedDownloadAttributes.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807D294A63A400263BE5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -527,6 +604,9 @@ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, A10000000000000000000001 /* LocationKeepAliveManager.swift in Sources */, A20000000000000000000001 /* GopeedTaskEventForwarder.m in Sources */, + 1E29080079AAFD1CF2C71262 /* GopeedDownloadAttributes.swift in Sources */, + 032447D610CFAF8A75642212 /* GopeedLiveActivityManager.swift in Sources */, + 5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -543,6 +623,12 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; + FBEBBD3469D2B152FDD0B424 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = GopeedLiveActivityExtension; + target = FFA6D07C1B1CB56543DE4B67 /* GopeedLiveActivityExtension */; + targetProxy = 03B416C78831516F6591BD24 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -693,7 +779,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -774,6 +860,61 @@ }; name = Profile; }; + 6CADF5E48DB3AF5F00998F2A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = GopeedLiveActivity/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Gopeed; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.GopeedLiveActivityExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 91F84B2C36416D1F9C500144 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = GopeedLiveActivity/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Gopeed; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.GopeedLiveActivityExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -823,7 +964,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -874,7 +1015,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -930,6 +1071,34 @@ }; name = Release; }; + ABCC87B2280A98A3C9E482CF /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = GopeedLiveActivity/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Gopeed; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.GopeedLiveActivityExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -973,6 +1142,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DAF208923F589C87CBB49D56 /* Build configuration list for PBXNativeTarget "GopeedLiveActivityExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6CADF5E48DB3AF5F00998F2A /* Release */, + 91F84B2C36416D1F9C500144 /* Debug */, + ABCC87B2280A98A3C9E482CF /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; diff --git a/ui/flutter/ios/Runner/AppDelegate.swift b/ui/flutter/ios/Runner/AppDelegate.swift index aac051ad..997da44a 100644 --- a/ui/flutter/ios/Runner/AppDelegate.swift +++ b/ui/flutter/ios/Runner/AppDelegate.swift @@ -14,10 +14,13 @@ import UIKit func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) let messenger = engineBridge.applicationRegistrar.messenger() + let libgopeedTaskQueue = messenger.makeBackgroundTaskQueue?() let libgopeedChannel = FlutterMethodChannel( name: "gopeed.com/libgopeed", - binaryMessenger: messenger + binaryMessenger: messenger, + codec: FlutterStandardMethodCodec.sharedInstance(), + taskQueue: libgopeedTaskQueue ) let taskEventForwarder = GopeedTaskEventForwarder(channel: libgopeedChannel) libgopeedChannel.setMethodCallHandler { call, result in @@ -56,11 +59,23 @@ import UIKit let path = arguments?["path"] as? String ?? "" let query = arguments?["query"] as? String ?? "" let body = arguments?["body"] as? String ?? "" - result(LibgopeedInvoke(method, path, query, body)) + let requestID = (arguments?["requestID"] as? NSNumber)?.int64Value ?? 0 + GopeedInvokeAsyncWithResult(method, path, query, body, requestID, result) case "subscribeTaskEvents": let arguments = call.arguments as? [String: Any] - let mask = (arguments?["mask"] as? NSNumber)?.int64Value ?? 0 - GopeedSubscribeTaskEventsWithForwarder(mask, mask == 0 ? nil : taskEventForwarder) + let flutterMask = + (arguments?["mask"] as? NSNumber)?.int64Value ?? 0 + + let liveActivityMask = + LibgopeedLiveActivityTaskEventMask() + + let combinedMask = + flutterMask | liveActivityMask + + GopeedSubscribeTaskEventsWithForwarder( + combinedMask, + taskEventForwarder + ) result(nil) default: result(FlutterMethodNotImplemented) @@ -87,5 +102,55 @@ import UIKit result(FlutterMethodNotImplemented) } } + let continuedProcessingChannel = + FlutterMethodChannel( + name: "gopeed/continued_processing", + binaryMessenger: messenger + ) + + continuedProcessingChannel + .setMethodCallHandler { call, result in + + switch call.method { + + case "isSupported": + + if #available(iOS 26.0, *) { + result(true) + } else { + result(false) + } + + case "setEnabled": + + let arguments = + call.arguments + as? [String: Any] + + let enabled = + arguments?["enabled"] + as? Bool + ?? false + + if #available(iOS 26.0, *) { + + GopeedContinuedProcessingManager + .shared + .setEnabled(enabled) { + success in + result(success) + } + + } else { + + result(!enabled) + } + + default: + result( + FlutterMethodNotImplemented + ) + } + } } } diff --git a/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift b/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift new file mode 100644 index 00000000..7ef2b852 --- /dev/null +++ b/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift @@ -0,0 +1,1117 @@ +import BackgroundTasks +import Foundation +import Libgopeed +import UIKit + +@available(iOS 26.0, *) +@objcMembers +final class GopeedContinuedProcessingManager: NSObject { + + static let shared = GopeedContinuedProcessingManager() + + // MARK: - Worker / state synchronization + + private let workerQueue = DispatchQueue( + label: "com.gopeed.continued-processing", + qos: .userInitiated + ) + + private let stateLock = NSLock() + private var enabledSnapshot = false + private var handledTaskIDs = Set() + + // MARK: - BGCPT state + + private var registeredIdentifiers = Set() + private var pendingTaskIDs = Set() + private struct ExpiringTaskState { + let identifier: String + let generation: UUID + } + + private var expiringTasks: + [String: ExpiringTaskState] = [:] + private var taskGenerations: [String: UUID] = [:] + + private var activeTasks: + [String: BGContinuedProcessingTask] = [:] + + private var taskIdentifiers: + [String: String] = [:] + + private var taskNames: + [String: String] = [:] + + private var lastProgressUpdate: + [String: Date] = [:] + + private var lastTitleUpdate: + [String: Date] = [:] + + private let minimumProgressUpdateInterval: + TimeInterval = 1.0 + + private let minimumTitleUpdateInterval: + TimeInterval = 1.0 + + private override init() { + super.init() + } + + + // MARK: - Synchronization helpers + + private func currentEnabledSnapshot() -> Bool { + stateLock.lock() + let value = enabledSnapshot + stateLock.unlock() + + return value + } + + private func setEnabledSnapshot( + _ enabled: Bool + ) { + stateLock.lock() + enabledSnapshot = enabled + stateLock.unlock() + } + + private func setHandledSnapshot( + taskID: String, + handled: Bool + ) { + stateLock.lock() + + if handled { + handledTaskIDs.insert(taskID) + } else { + handledTaskIDs.remove(taskID) + } + + stateLock.unlock() + } + + private func handledTaskIDsSnapshot() -> [String] { + stateLock.lock() + let ids = Array(handledTaskIDs) + stateLock.unlock() + + return ids + } + + private func setTaskOwnership( + taskID: String, + handled: Bool + ) { + setHandledSnapshot( + taskID: taskID, + handled: handled + ) + + // This is a second layer of protection in addition to the + // Objective-C forwarder. If Continued Processing owns a task, + // the custom ActivityKit manager must not create a second + // Live Activity for it. + GopeedLiveActivityManager.shared + .setTaskSuppressed( + handled, + taskID: taskID + ) + } + + + // MARK: - Setting + + func setEnabled( + _ enabled: Bool, + completion: @escaping (Bool) -> Void + ) { + workerQueue.async { [weak self] in + guard let self else { + DispatchQueue.main.async { + completion(false) + } + return + } + + self.setEnabledSnapshot(enabled) + + if !enabled { + self.stopAllContinuedTasks() + } + + print( + "ContinuedProcessing: enabled =", + enabled + ) + + DispatchQueue.main.async { + completion(true) + } + } + } + + + // MARK: - Event entry point + + func handleTaskEventPayload( + _ payload: String + ) { + + guard currentEnabledSnapshot() else { + return + } + + // Pre-mark task.start so the Objective-C forwarder can + // immediately suppress the custom ActivityKit Live Activity. + if + let data = payload.data(using: .utf8), + let json = try? JSONSerialization.jsonObject( + with: data + ) as? [String: Any], + let type = json["type"] as? String, + type == "task.start", + let taskID = json["taskId"] as? String + { + setTaskOwnership( + taskID: taskID, + handled: true + ) + } + + // IMPORTANT: asynchronous, so Gopeed's event thread + // is never blocked by BGCPT bookkeeping. + workerQueue.async { [weak self] in + self?.handleTaskEventPayloadOnWorker( + payload + ) + } + } + + private func handleTaskEventPayloadOnWorker( + _ payload: String + ) { + + guard currentEnabledSnapshot() else { + return + } + + guard + let data = payload.data( + using: .utf8 + ), + let json = + try? JSONSerialization + .jsonObject( + with: data + ) as? [String: Any], + let type = + json["type"] as? String, + let taskID = + json["taskId"] as? String + else { + print( + "ContinuedProcessing: invalid event" + ) + return + } + + let name = + json["name"] as? String + ?? "Download" + + switch type { + + case "task.start": + beginTask( + taskID: taskID, + name: name + ) + + case "task.progress": + if let generation = + taskGenerations[taskID] { + updateProgress( + taskID: taskID, + generation: generation, + force: false + ) + } + + case "task.pause": + if let expiring = + expiringTasks[taskID] { + finishExpirationCleanup( + taskID: taskID, + identifier: expiring.identifier, + generation: expiring.generation + ) + } else { + finishTask( + taskID: taskID, + success: true, + finalSubtitle: "Paused" + ) + } + + case "task.done": + finishTask( + taskID: taskID, + success: true, + finalSubtitle: + "Download complete" + ) + + case "task.error": + finishTask( + taskID: taskID, + success: false, + finalSubtitle: + "Download failed" + ) + + case "task.delete": + finishTask( + taskID: taskID, + success: true, + finalSubtitle: + "Download removed" + ) + + default: + break + } + } + + + // MARK: - Used by Objective-C forwarder + + @objc(isHandlingTaskId:) + func isHandlingTaskId( + _ taskID: String + ) -> Bool { + + // Do not wait for workerQueue here. The forwarder calls + // this for frequent progress events. + stateLock.lock() + let handled = handledTaskIDs.contains(taskID) + stateLock.unlock() + + return handled + } + + + // MARK: - Start BGCPT + + private func beginTask( + taskID: String, + name: String + ) { + + guard currentEnabledSnapshot() else { + setTaskOwnership( + taskID: taskID, + handled: false + ) + return + } + + guard + !pendingTaskIDs.contains(taskID), + activeTasks[taskID] == nil + else { + setTaskOwnership( + taskID: taskID, + handled: true + ) + return + } + + // BGCPT should originate from a foreground user action. + // This main-thread check happens only once per task start. + let appIsActive: Bool + + if Thread.isMainThread { + appIsActive = + UIApplication.shared + .applicationState == .active + } else { + appIsActive = + DispatchQueue.main.sync { + UIApplication.shared + .applicationState == .active + } + } + + guard appIsActive else { + print( + "ContinuedProcessing:", + "ignored non-foreground start", + taskID + ) + + setTaskOwnership( + taskID: taskID, + handled: false + ) + return + } + + let generation = UUID() + taskGenerations[taskID] = generation + + let identifier = + makeIdentifier( + taskID: taskID, + generation: generation + ) + + taskIdentifiers[taskID] = identifier + taskNames[taskID] = name + + let registered = + BGTaskScheduler.shared.register( + forTaskWithIdentifier: identifier, + using: nil + ) { [weak self] task in + guard + let self, + let continuedTask = + task as? BGContinuedProcessingTask + else { + task.setTaskCompleted( + success: false + ) + return + } + + self.workerQueue.async { + self.activateTask( + continuedTask, + taskID: taskID, + name: name, + identifier: identifier, + generation: generation + ) + } + } + + guard registered else { + print( + "ContinuedProcessing:", + "registration failed:", + identifier + ) + + if taskGenerations[taskID] == generation { + taskGenerations.removeValue( + forKey: taskID + ) + taskIdentifiers.removeValue( + forKey: taskID + ) + taskNames.removeValue( + forKey: taskID + ) + setTaskOwnership( + taskID: taskID, + handled: false + ) + } + return + } + + registeredIdentifiers.insert(identifier) + + let request = + BGContinuedProcessingTaskRequest( + identifier: identifier, + title: name, + subtitle: "Preparing download…" + ) + + request.strategy = .fail + + pendingTaskIDs.insert(taskID) + + setTaskOwnership( + taskID: taskID, + handled: true + ) + + do { + try BGTaskScheduler.shared.submit( + request + ) + + print( + "ContinuedProcessing:", + "submitted:", + identifier + ) + } catch { + if taskGenerations[taskID] == generation { + pendingTaskIDs.remove(taskID) + taskGenerations.removeValue( + forKey: taskID + ) + + if taskIdentifiers[taskID] + == identifier { + taskIdentifiers.removeValue( + forKey: taskID + ) + } + + taskNames.removeValue( + forKey: taskID + ) + + setTaskOwnership( + taskID: taskID, + handled: false + ) + } + + print( + "ContinuedProcessing:", + "submission failed:", + error + ) + } + } + + + // MARK: - System launched task + + private func activateTask( + _ task: BGContinuedProcessingTask, + taskID: String, + name: String, + identifier: String, + generation: UUID + ) { + guard + currentEnabledSnapshot(), + taskGenerations[taskID] == generation, + pendingTaskIDs.contains(taskID), + taskIdentifiers[taskID] == identifier + else { + task.setTaskCompleted( + success: false + ) + return + } + + pendingTaskIDs.remove(taskID) + + activeTasks[taskID] = task + taskNames[taskID] = name + + setTaskOwnership( + taskID: taskID, + handled: true + ) + + task.progress.totalUnitCount = 100 + task.progress.completedUnitCount = 0 + + task.expirationHandler = { + [weak self, weak task] in + guard + let self, + let task + else { + return + } + + self.workerQueue.async { + self.handleExpiration( + task, + taskID: taskID, + identifier: identifier, + generation: generation + ) + } + } + + print( + "ContinuedProcessing:", + "started:", + taskID + ) + + updateProgress( + taskID: taskID, + generation: generation, + force: true + ) + } + + + // MARK: - Async Gopeed invocation + + private func invokeGopeed( + method: String, + path: String, + query: String = "", + body: String = "", + completion: + @escaping (String?) -> Void + ) { + + GopeedInvokeAsyncNative( + method, + path, + query, + body + ) { [weak self] success, payload in + + guard let self else { + return + } + + self.workerQueue.async { + completion( + success + ? payload + : nil + ) + } + } + } + + + // MARK: - Progress + + private struct RuntimeStatus { + let status: String + let downloaded: Int64 + let total: Int64 + let speed: Int64 + } + + private func getRuntimeStatus( + taskID: String, + completion: + @escaping (RuntimeStatus?) -> Void + ) { + + invokeGopeed( + method: "GET", + path: + "/api/v1/tasks/\(taskID)/status" + ) { response in + + guard + let response, + let data = + response.data( + using: .utf8 + ), + let root = + try? JSONSerialization + .jsonObject( + with: data + ) as? [String: Any], + let code = + (root["code"] as? NSNumber)? + .intValue, + code == 0, + let body = + root["data"] + as? [String: Any] + else { + completion(nil) + return + } + + completion( + RuntimeStatus( + status: + body["status"] + as? String ?? "", + downloaded: + (body["downloaded"] + as? NSNumber)? + .int64Value ?? 0, + total: + (body["total"] + as? NSNumber)? + .int64Value ?? 0, + speed: + (body["speed"] + as? NSNumber)? + .int64Value ?? 0 + ) + ) + } + } + + private func updateProgress( + taskID: String, + generation: UUID, + force: Bool + ) { + + guard + taskGenerations[taskID] == generation, + activeTasks[taskID] != nil + else { + return + } + + let requestTime = Date() + + if !force, + let previous = + lastProgressUpdate[taskID], + requestTime.timeIntervalSince(previous) + < minimumProgressUpdateInterval { + + return + } + + // Mark before starting the async request so frequent + // task.progress events cannot create overlapping + // status requests. + lastProgressUpdate[taskID] = + requestTime + + getRuntimeStatus( + taskID: taskID + ) { [weak self] runtime in + + guard + let self, + let runtime, + self.taskGenerations[taskID] + == generation, + let task = + self.activeTasks[taskID] + else { + return + } + + let now = Date() + + let shouldUpdateTitle: Bool + + if force { + + shouldUpdateTitle = true + + } else if let previous = + self.lastTitleUpdate[ + taskID + ] { + + shouldUpdateTitle = + now.timeIntervalSince( + previous + ) + >= + self.minimumTitleUpdateInterval + + } else { + + shouldUpdateTitle = true + } + + let downloaded = + max( + runtime.downloaded, + 0 + ) + + let name = + self.taskNames[taskID] + ?? "Download" + + if runtime.total > 0 { + + let total = + max(runtime.total, 1) + + let completed = + min( + downloaded, + total + ) + + task.progress + .totalUnitCount = + total + + task.progress + .completedUnitCount = + completed + + if shouldUpdateTitle { + + let percent = + Int( + ( + Double( + completed + ) + / Double(total) + * 100.0 + ).rounded() + ) + + var subtitle = + "\(percent)% • " + + "\(self.formatBytes(completed)) / " + + "\(self.formatBytes(total))" + + if runtime.speed > 0 { + subtitle += + " • " + + "\(self.formatBytes(runtime.speed))/s" + } + + task.updateTitle( + name, + subtitle: + subtitle + ) + + self.lastTitleUpdate[ + taskID + ] = now + } + + } else { + + task.progress + .totalUnitCount = 100 + + task.progress + .completedUnitCount = 0 + + if shouldUpdateTitle { + + var subtitle = + self.formatBytes( + downloaded + ) + + if runtime.speed > 0 { + subtitle += + " • " + + "\(self.formatBytes(runtime.speed))/s" + } + + task.updateTitle( + name, + subtitle: + subtitle + ) + + self.lastTitleUpdate[ + taskID + ] = now + } + } + } + } + + // MARK: - Completion + + private func finishTask( + taskID: String, + success: Bool, + finalSubtitle: String + ) { + taskGenerations.removeValue( + forKey: taskID + ) + + expiringTasks.removeValue( + forKey: taskID + ) + + if let identifier = + taskIdentifiers[taskID] { + + BGTaskScheduler.shared.cancel( + taskRequestWithIdentifier: + identifier + ) + } + + pendingTaskIDs.remove(taskID) + + if let task = + activeTasks.removeValue( + forKey: taskID + ) { + + if success, + finalSubtitle == + "Download complete", + task.progress + .totalUnitCount > 0 { + + task.progress + .completedUnitCount = + task.progress + .totalUnitCount + } + + task.updateTitle( + taskNames[taskID] + ?? "Download", + subtitle: + finalSubtitle + ) + + task.setTaskCompleted( + success: success + ) + } + + taskIdentifiers.removeValue( + forKey: taskID + ) + + taskNames.removeValue( + forKey: taskID + ) + + lastProgressUpdate.removeValue( + forKey: taskID + ) + + lastTitleUpdate.removeValue( + forKey: taskID + ) + + setTaskOwnership( + taskID: taskID, + handled: false + ) + + print( + "ContinuedProcessing:", + "finished:", + taskID, + "success:", + success + ) + } + + // MARK: - Expiration / system cancellation + + private func handleExpiration( + _ task: BGContinuedProcessingTask, + taskID: String, + identifier: String, + generation: UUID + ) { + guard + taskGenerations[taskID] == generation, + taskIdentifiers[taskID] == identifier + else { + task.setTaskCompleted( + success: false + ) + return + } + + taskGenerations.removeValue( + forKey: taskID + ) + + activeTasks.removeValue( + forKey: taskID + ) + + pendingTaskIDs.remove(taskID) + expiringTasks[taskID] = + ExpiringTaskState( + identifier: identifier, + generation: generation + ) + + lastProgressUpdate.removeValue( + forKey: taskID + ) + + lastTitleUpdate.removeValue( + forKey: taskID + ) + + // Keep ownership/suppression active until the pause request + // has been delivered. Otherwise progress events arriving in + // this small window can start a custom ActivityKit activity. + setTaskOwnership( + taskID: taskID, + handled: true + ) + + print( + "ContinuedProcessing:", + "expired/cancelled:", + taskID + ) + + task.setTaskCompleted( + success: false + ) + + invokeGopeed( + method: "PUT", + path: + "/api/v1/tasks/\(taskID)/pause" + ) { [weak self] _ in + self?.finishExpirationCleanup( + taskID: taskID, + identifier: identifier, + generation: generation + ) + } + + // Defensive fallback in case the async bridge never calls + // back because the process is being suspended. + workerQueue.asyncAfter( + deadline: .now() + 2.0 + ) { [weak self] in + self?.finishExpirationCleanup( + taskID: taskID, + identifier: identifier, + generation: generation + ) + } + } + + private func finishExpirationCleanup( + taskID: String, + identifier: String, + generation: UUID + ) { + guard + let expiring = expiringTasks[taskID], + expiring.generation == generation, + expiring.identifier == identifier + else { + return + } + + expiringTasks.removeValue( + forKey: taskID + ) + + if taskIdentifiers[taskID] + == identifier { + taskIdentifiers.removeValue( + forKey: taskID + ) + + if taskGenerations[taskID] == nil { + taskNames.removeValue( + forKey: taskID + ) + } + } + + // Never clear ownership for a newer start of the same task ID. + if taskGenerations[taskID] == nil { + setTaskOwnership( + taskID: taskID, + handled: false + ) + } + } + + + // MARK: - Disable all BGCPT tasks + + private func stopAllContinuedTasks() { + + let ownedTaskIDs = + Set( + handledTaskIDsSnapshot() + + Array(taskIdentifiers.keys) + + Array(pendingTaskIDs) + + Array(activeTasks.keys) + + Array(expiringTasks.keys) + ) + + for ( + taskID, + identifier + ) in taskIdentifiers { + + BGTaskScheduler.shared.cancel( + taskRequestWithIdentifier: + identifier + ) + + if let task = + activeTasks[taskID] { + + task.setTaskCompleted( + success: true + ) + } + } + + activeTasks.removeAll() + pendingTaskIDs.removeAll() + expiringTasks.removeAll() + taskGenerations.removeAll() + taskIdentifiers.removeAll() + taskNames.removeAll() + lastProgressUpdate.removeAll() + lastTitleUpdate.removeAll() + + for taskID in ownedTaskIDs { + setTaskOwnership( + taskID: taskID, + handled: false + ) + } + } + + + // MARK: - Helpers + + private func makeIdentifier( + taskID: String, + generation: UUID + ) -> String { + + let bundleID = + Bundle.main.bundleIdentifier + ?? "com.gopeed.gopeed" + + let safeID = + taskID.replacingOccurrences( + of: "[^A-Za-z0-9_-]", + with: "-", + options: + .regularExpression + ) + + return + "\(bundleID)" + + ".continuedDownload." + + safeID + + "." + + generation.uuidString.lowercased() + } + + private func formatBytes( + _ bytes: Int64 + ) -> String { + + return ByteCountFormatter.string( + fromByteCount: + max(bytes, 0), + countStyle: .file + ) + } +} diff --git a/ui/flutter/ios/Runner/GopeedDownloadAttributes.swift b/ui/flutter/ios/Runner/GopeedDownloadAttributes.swift new file mode 100644 index 00000000..a4a127b0 --- /dev/null +++ b/ui/flutter/ios/Runner/GopeedDownloadAttributes.swift @@ -0,0 +1,28 @@ +import ActivityKit +import Foundation + +@available(iOS 16.2, *) +struct GopeedDownloadAttributes: ActivityAttributes { + + struct ContentState: Codable, Hashable { + + // Real confirmed progress. + var progress: Double + + var downloaded: Int64 + var total: Int64 + var speed: Int64 + + var status: String + + // Used by the self-moving estimated progress bar. + var estimatedStart: Date + var estimatedEnd: Date + var usesEstimatedProgress: Bool + } + + // Static properties. + var taskId: String + var fileName: String + var generation: String +} diff --git a/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift b/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift new file mode 100644 index 00000000..84f965ba --- /dev/null +++ b/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift @@ -0,0 +1,1050 @@ +import ActivityKit +import Foundation +import Libgopeed +import UIKit + +@objcMembers +final class GopeedLiveActivityManager: NSObject { + + static let shared = GopeedLiveActivityManager() + + // Gopeed emits progress approximately every 350 ms. + // There is no reason to hit ActivityKit that frequently. + private let minimumUpdateInterval: TimeInterval = 2.0 + + private let stateLock = NSLock() + + private var lastUpdateTime: [String: Date] = [:] + private var suppressedTaskIDs = Set() + private var taskGenerations: [String: UUID] = [:] + private var activityCreationInProgress: + [String: UUID] = [:] + + private override init() { + super.init() + } + + + // MARK: - BGCPT coordination + + func setTaskSuppressed( + _ suppressed: Bool, + taskID: String + ) { + stateLock.lock() + + if suppressed { + suppressedTaskIDs.insert(taskID) + taskGenerations.removeValue( + forKey: taskID + ) + lastUpdateTime.removeValue( + forKey: taskID + ) + } else { + suppressedTaskIDs.remove(taskID) + } + + stateLock.unlock() + + if suppressed { + guard #available(iOS 16.2, *) else { + return + } + + Task { [weak self] in + guard let self else { + return + } + + // Suppression may have been removed again if BGCPT + // submission failed before this async cleanup runs. + guard self.isTaskSuppressed(taskID) else { + return + } + + await self.removeAllActivities( + taskID: taskID, + reason: "suppressed by continued processing" + ) + } + } + } + + private func isTaskSuppressed( + _ taskID: String + ) -> Bool { + stateLock.lock() + let suppressed = + suppressedTaskIDs.contains(taskID) + stateLock.unlock() + + return suppressed + } + + private func startGeneration( + taskID: String + ) -> UUID { + let generation = UUID() + + stateLock.lock() + taskGenerations[taskID] = generation + lastUpdateTime.removeValue( + forKey: taskID + ) + stateLock.unlock() + + return generation + } + + private func currentGeneration( + taskID: String + ) -> UUID? { + stateLock.lock() + let generation = + taskGenerations[taskID] + stateLock.unlock() + + return generation + } + + @discardableResult + private func invalidateGeneration( + taskID: String + ) -> UUID? { + stateLock.lock() + let generation = + taskGenerations.removeValue( + forKey: taskID + ) + lastUpdateTime.removeValue( + forKey: taskID + ) + stateLock.unlock() + + return generation + } + + private func isCurrentGeneration( + taskID: String, + generation: UUID + ) -> Bool { + stateLock.lock() + let isCurrent = + taskGenerations[taskID] + == generation + stateLock.unlock() + + return isCurrent + } + + private func beginActivityCreation( + taskID: String, + generation: UUID + ) -> Bool { + stateLock.lock() + defer { stateLock.unlock() } + + guard + !suppressedTaskIDs.contains(taskID), + taskGenerations[taskID] == generation + else { + return false + } + + if activityCreationInProgress[taskID] + == generation { + return false + } + + activityCreationInProgress[taskID] = + generation + + return true + } + + private func endActivityCreation( + taskID: String, + generation: UUID + ) { + stateLock.lock() + + if activityCreationInProgress[taskID] + == generation { + activityCreationInProgress + .removeValue( + forKey: taskID + ) + } + + stateLock.unlock() + } + + private func shouldHandleProgress( + taskID: String, + generation: UUID, + now: Date + ) -> Bool { + stateLock.lock() + defer { stateLock.unlock() } + + guard + !suppressedTaskIDs.contains(taskID), + taskGenerations[taskID] + == generation + else { + return false + } + + if let previous = lastUpdateTime[taskID], + now.timeIntervalSince(previous) + < minimumUpdateInterval { + return false + } + + lastUpdateTime[taskID] = now + return true + } + + // MARK: - Gopeed event entry point + + @objc + func handleTaskEventPayload(_ payload: String) { + + guard #available(iOS 16.2, *) else { + return + } + + guard + let data = payload.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) + as? [String: Any], + let type = json["type"] as? String, + let taskID = json["taskId"] as? String + else { + print("LiveActivity: invalid task event payload") + return + } + + guard !isTaskSuppressed(taskID) else { + return + } + + let name = json["name"] as? String ?? "Download" + + switch type { + + case "task.start": + let generation = + startGeneration( + taskID: taskID + ) + + Task { + await refreshActivity( + taskID: taskID, + name: name, + generation: generation, + allowStart: true + ) + } + + case "task.progress": + guard + let generation = + currentGeneration( + taskID: taskID + ) + else { + return + } + + handleProgress( + taskID: taskID, + name: name, + generation: generation + ) + + case "task.pause": + if let generation = + invalidateGeneration( + taskID: taskID + ) { + Task { + await removeActivity( + taskID: taskID, + generation: generation, + reason: "paused" + ) + } + } + + case "task.done": + if let generation = + invalidateGeneration( + taskID: taskID + ) { + Task { + await finishActivity( + taskID: taskID, + generation: generation + ) + } + } + + case "task.error": + let error = + json["error"] as? String + ?? "Download failed" + + if let generation = + invalidateGeneration( + taskID: taskID + ) { + Task { + await failActivity( + taskID: taskID, + generation: generation, + error: error + ) + } + } + + case "task.delete": + if let generation = + invalidateGeneration( + taskID: taskID + ) { + Task { + await removeActivity( + taskID: taskID, + generation: generation, + reason: "removed" + ) + } + } + + default: + break + } + } + + + // MARK: - Progress throttling + + @available(iOS 16.2, *) + private func handleProgress( + taskID: String, + name: String, + generation: UUID + ) { + let now = Date() + + guard shouldHandleProgress( + taskID: taskID, + generation: generation, + now: now + ) else { + return + } + + Task { + await refreshActivity( + taskID: taskID, + name: name, + generation: generation, + allowStart: true + ) + } + } + + + // MARK: - Async Gopeed invocation + + private func invokeGopeed( + method: String, + path: String, + query: String = "", + body: String = "" + ) async -> String? { + + await withCheckedContinuation { + continuation in + + GopeedInvokeAsyncNative( + method, + path, + query, + body + ) { success, payload in + + continuation.resume( + returning: + success + ? payload + : nil + ) + } + } + } + + + // MARK: - Gopeed runtime status + + private struct RuntimeStatus { + let status: String + let downloaded: Int64 + let total: Int64 + let speed: Int64 + } + + private func getRuntimeStatus( + taskID: String + ) async -> RuntimeStatus? { + + guard + let response = + await invokeGopeed( + method: "GET", + path: + "/api/v1/tasks/\(taskID)/status" + ) + else { + print( + "LiveActivity: status request failed for \(taskID)" + ) + return nil + } + + guard + let data = response.data(using: .utf8), + let root = + try? JSONSerialization.jsonObject( + with: data + ) as? [String: Any] + else { + print( + "LiveActivity: failed to decode status for \(taskID)" + ) + return nil + } + + let code = + (root["code"] as? NSNumber)?.intValue + ?? -1 + + guard code == 0 else { + let message = + root["msg"] as? String + ?? "Unknown Gopeed API error" + + print( + "LiveActivity: Gopeed status error:", + message + ) + + return nil + } + + guard + let body = + root["data"] as? [String: Any] + else { + return nil + } + + return RuntimeStatus( + status: + body["status"] as? String ?? "", + downloaded: + (body["downloaded"] as? NSNumber)? + .int64Value ?? 0, + total: + (body["total"] as? NSNumber)? + .int64Value ?? 0, + speed: + (body["speed"] as? NSNumber)? + .int64Value ?? 0 + ) + } + + // MARK: - Build Live Activity state + + @available(iOS 16.2, *) + private func makeState( + from runtime: RuntimeStatus + ) -> GopeedDownloadAttributes.ContentState { + + let now = Date() + + let total = + max(runtime.total, 0) + + let downloaded: Int64 + + if total > 0 { + downloaded = + min( + max(runtime.downloaded, 0), + total + ) + } else { + downloaded = + max(runtime.downloaded, 0) + } + + let progress: Double + + if total > 0 { + progress = + min( + max( + Double(downloaded) + / Double(total), + 0 + ), + 1 + ) + } else { + progress = 0 + } + + // If we don't have enough information for an ETA, + // use the real static progress bar. + guard + runtime.status == "running", + total > 0, + runtime.speed > 0, + downloaded < total, + progress > 0, + progress < 1 + else { + return GopeedDownloadAttributes + .ContentState( + progress: progress, + downloaded: downloaded, + total: total, + speed: runtime.speed, + status: runtime.status, + estimatedStart: now, + estimatedEnd: + now.addingTimeInterval(1), + usesEstimatedProgress: false + ) + } + + let remainingBytes = + Double(total - downloaded) + + var remainingSeconds = + remainingBytes + / Double(runtime.speed) + + // Don't allow corrupt speed values to create + // ridiculous date ranges. + remainingSeconds = + min( + max(remainingSeconds, 1), + 86_400 + ) + + let remainingFraction = + max(1.0 - progress, 0.001) + + let estimatedTotalDuration = + remainingSeconds + / remainingFraction + + let elapsedEstimate = + estimatedTotalDuration + * progress + + let estimatedStart = + now.addingTimeInterval( + -elapsedEstimate + ) + + let estimatedEnd = + now.addingTimeInterval( + remainingSeconds + ) + + return GopeedDownloadAttributes + .ContentState( + progress: progress, + downloaded: downloaded, + total: total, + speed: runtime.speed, + status: runtime.status, + estimatedStart: estimatedStart, + estimatedEnd: estimatedEnd, + usesEstimatedProgress: true + ) + } + + + // MARK: - Find existing Activity + + @available(iOS 16.2, *) + private func findActivities( + taskID: String + ) -> [Activity] { + return Activity< + GopeedDownloadAttributes + > + .activities + .filter { + $0.attributes.taskId == taskID + } + } + + @available(iOS 16.2, *) + private func findActivities( + taskID: String, + generation: UUID + ) -> [Activity] { + let generationID = + generation.uuidString + + return findActivities( + taskID: taskID + ) + .filter { + $0.attributes.generation + == generationID + } + } + + @available(iOS 16.2, *) + private func collapseDuplicateActivities( + _ activities: [Activity], + content: ActivityContent + ) async { + guard let primary = activities.first else { + return + } + + await primary.update(content) + + if activities.count > 1 { + for duplicate in activities.dropFirst() { + await duplicate.end( + nil, + dismissalPolicy: .immediate + ) + } + + print( + "LiveActivity: removed", + activities.count - 1, + "duplicate activity/activities for", + primary.attributes.taskId + ) + } + } + + @available(iOS 16.2, *) + private func removeStaleActivities( + taskID: String, + keeping generation: UUID + ) async { + let generationID = + generation.uuidString + + let stale = + findActivities(taskID: taskID) + .filter { + $0.attributes.generation + != generationID + } + + for activity in stale { + await activity.end( + nil, + dismissalPolicy: .immediate + ) + } + } + + + // MARK: - Start / update Activity + + @available(iOS 16.2, *) + private func refreshActivity( + taskID: String, + name: String, + generation: UUID, + allowStart: Bool + ) async { + guard + !isTaskSuppressed(taskID), + isCurrentGeneration( + taskID: taskID, + generation: generation + ) + else { + return + } + + guard + let runtime = + await getRuntimeStatus( + taskID: taskID + ) + else { + return + } + + guard + !isTaskSuppressed(taskID), + isCurrentGeneration( + taskID: taskID, + generation: generation + ) + else { + return + } + + let state = + makeState(from: runtime) + + let content = + ActivityContent( + state: state, + staleDate: nil + ) + + let existing = + findActivities( + taskID: taskID, + generation: generation + ) + + if !existing.isEmpty { + await collapseDuplicateActivities( + existing, + content: content + ) + return + } + + guard + allowStart, + runtime.status == "running" + else { + return + } + + guard + ActivityAuthorizationInfo() + .areActivitiesEnabled + else { + print( + "LiveActivity: activities disabled" + ) + return + } + + let appIsActive = + await MainActor.run { + UIApplication.shared + .applicationState == .active + } + + guard + appIsActive, + !isTaskSuppressed(taskID), + isCurrentGeneration( + taskID: taskID, + generation: generation + ) + else { + return + } + + guard beginActivityCreation( + taskID: taskID, + generation: generation + ) else { + return + } + + defer { + endActivityCreation( + taskID: taskID, + generation: generation + ) + } + + guard + !isTaskSuppressed(taskID), + isCurrentGeneration( + taskID: taskID, + generation: generation + ) + else { + return + } + + await removeStaleActivities( + taskID: taskID, + keeping: generation + ) + + guard + !isTaskSuppressed(taskID), + isCurrentGeneration( + taskID: taskID, + generation: generation + ) + else { + return + } + + let recheck = + findActivities( + taskID: taskID, + generation: generation + ) + + if !recheck.isEmpty { + await collapseDuplicateActivities( + recheck, + content: content + ) + return + } + + let attributes = + GopeedDownloadAttributes( + taskId: taskID, + fileName: name, + generation: + generation.uuidString + ) + + do { + let activity = + try Activity.request( + attributes: attributes, + content: content, + pushType: nil + ) + + if + isTaskSuppressed(taskID) + || !isCurrentGeneration( + taskID: taskID, + generation: generation + ) + { + await activity.end( + nil, + dismissalPolicy: .immediate + ) + return + } + + print( + "LiveActivity: started", + activity.id, + taskID + ) + } catch { + print( + "LiveActivity: start failed:", + error + ) + } + } + + + // MARK: - Complete Activity + + @available(iOS 16.2, *) + private func finishActivity( + taskID: String, + generation: UUID + ) async { + let activities = + findActivities( + taskID: taskID, + generation: generation + ) + + guard !activities.isEmpty else { + return + } + + let now = Date() + + for activity in activities { + let oldState = + activity.content.state + + let finalTotal = + max( + oldState.total, + oldState.downloaded + ) + + let finalState = + GopeedDownloadAttributes + .ContentState( + progress: 1.0, + downloaded: finalTotal, + total: finalTotal, + speed: 0, + status: "done", + estimatedStart: now, + estimatedEnd: + now.addingTimeInterval(1), + usesEstimatedProgress: false + ) + + let finalContent = + ActivityContent( + state: finalState, + staleDate: nil + ) + + await activity.end( + finalContent, + dismissalPolicy: + .after( + Date() + .addingTimeInterval(15) + ) + ) + } + + print( + "LiveActivity: completed", + taskID + ) + } + + + // MARK: - Error Activity + + @available(iOS 16.2, *) + private func failActivity( + taskID: String, + generation: UUID, + error: String + ) async { + let activities = + findActivities( + taskID: taskID, + generation: generation + ) + + guard !activities.isEmpty else { + return + } + + let now = Date() + + for activity in activities { + let old = + activity.content.state + + let failedState = + GopeedDownloadAttributes + .ContentState( + progress: old.progress, + downloaded: old.downloaded, + total: old.total, + speed: 0, + status: "error", + estimatedStart: now, + estimatedEnd: + now.addingTimeInterval(1), + usesEstimatedProgress: false + ) + + let content = + ActivityContent( + state: failedState, + staleDate: nil + ) + + await activity.end( + content, + dismissalPolicy: + .after( + Date() + .addingTimeInterval(30) + ) + ) + } + + print( + "LiveActivity: task failed:", + taskID, + error + ) + } + + + // MARK: - Delete / suppression cleanup + + @available(iOS 16.2, *) + private func removeActivity( + taskID: String, + generation: UUID, + reason: String + ) async { + let activities = + findActivities( + taskID: taskID, + generation: generation + ) + + for activity in activities { + await activity.end( + nil, + dismissalPolicy: .immediate + ) + } + + if !activities.isEmpty { + print( + "LiveActivity:", + reason, + taskID, + "count:", + activities.count + ) + } + } + + @available(iOS 16.2, *) + private func removeAllActivities( + taskID: String, + reason: String + ) async { + let activities = + findActivities( + taskID: taskID + ) + + for activity in activities { + await activity.end( + nil, + dismissalPolicy: .immediate + ) + } + + if !activities.isEmpty { + print( + "LiveActivity:", + reason, + taskID, + "count:", + activities.count + ) + } + } + +} diff --git a/ui/flutter/ios/Runner/GopeedTaskEventForwarder.h b/ui/flutter/ios/Runner/GopeedTaskEventForwarder.h index e12dd1fd..2a63353d 100644 --- a/ui/flutter/ios/Runner/GopeedTaskEventForwarder.h +++ b/ui/flutter/ios/Runner/GopeedTaskEventForwarder.h @@ -13,4 +13,24 @@ FOUNDATION_EXPORT void GopeedSubscribeTaskEventsWithForwarder( int64_t mask, GopeedTaskEventForwarder * _Nullable listener); + +typedef void (^GopeedNativeInvokeCompletion)( + BOOL success, + NSString *payload); + +FOUNDATION_EXPORT void GopeedInvokeAsyncNative( + NSString *method, + NSString *path, + NSString *query, + NSString *body, + GopeedNativeInvokeCompletion completion); + +FOUNDATION_EXPORT void GopeedInvokeAsyncWithResult( + NSString *method, + NSString *path, + NSString *query, + NSString *body, + int64_t requestID, + FlutterResult result); + NS_ASSUME_NONNULL_END diff --git a/ui/flutter/ios/Runner/GopeedTaskEventForwarder.m b/ui/flutter/ios/Runner/GopeedTaskEventForwarder.m index c6bcee5e..d0330667 100644 --- a/ui/flutter/ios/Runner/GopeedTaskEventForwarder.m +++ b/ui/flutter/ios/Runner/GopeedTaskEventForwarder.m @@ -2,12 +2,92 @@ #import +#import "Runner-Swift.h" + @interface GopeedTaskEventForwarder () @property(nonatomic, strong) FlutterMethodChannel *channel; @end +@interface GopeedInvokeResultForwarder : NSObject + +@property(nonatomic, copy, nullable) FlutterResult result; + +- (instancetype)initWithResult:(FlutterResult)result; + +@end + +@implementation GopeedInvokeResultForwarder + +- (instancetype)initWithResult:(FlutterResult)result { + self = [super init]; + if (self) { + _result = [result copy]; + } + return self; +} + +- (void)onResult:(int64_t)requestID + success:(BOOL)success + payload:(NSString * _Nullable)payload { + FlutterResult result = self.result; + self.result = nil; + if (result == nil) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (success) { + result(payload ?: @""); + } else { + result([FlutterError errorWithCode:@"ERROR" + message:payload ?: @"InvokeAsync failed" + details:nil]); + } + }); +} + +@end + + +@interface GopeedNativeInvokeResultForwarder + : NSObject + +@property(nonatomic, copy, nullable) + GopeedNativeInvokeCompletion completion; + +- (instancetype)initWithCompletion: + (GopeedNativeInvokeCompletion)completion; + +@end + +@implementation GopeedNativeInvokeResultForwarder + +- (instancetype)initWithCompletion: + (GopeedNativeInvokeCompletion)completion { + self = [super init]; + if (self) { + _completion = [completion copy]; + } + return self; +} + +- (void)onResult:(int64_t)requestID + success:(BOOL)success + payload:(NSString * _Nullable)payload { + GopeedNativeInvokeCompletion completion = + self.completion; + self.completion = nil; + + if (completion == nil) { + return; + } + + completion(success, payload ?: @""); +} + +@end + @implementation GopeedTaskEventForwarder - (instancetype)initWithChannel:(FlutterMethodChannel *)channel { @@ -20,9 +100,59 @@ - (void)onTaskEvent:(NSString * _Nullable)payload { NSString *arguments = payload ?: @""; + + NSData *data = + [arguments dataUsingEncoding:NSUTF8StringEncoding]; + + NSDictionary *json = nil; + + if (data) { + json = + [NSJSONSerialization JSONObjectWithData:data + options:0 + error:nil]; + } + + NSString *type = json[@"type"]; + NSString *taskID = json[@"taskId"]; + + BOOL continuedProcessingHandlesTask = NO; + + // iOS 26 system continued-processing Live Activity. + if (@available(iOS 26.0, *)) { + GopeedContinuedProcessingManager *manager = + [GopeedContinuedProcessingManager shared]; + + [manager handleTaskEventPayload:arguments]; + + if (taskID.length > 0) { + continuedProcessingHandlesTask = + [manager isHandlingTaskId:taskID]; + } + } + + // When BGCPT is active, let Apple's system Live Activity + // represent progress. Otherwise keep using our existing + // custom Gopeed ActivityKit implementation. + if (!continuedProcessingHandlesTask) { + [[GopeedLiveActivityManager shared] + handleTaskEventPayload:arguments]; + } + + // Flutter currently understands only done/error. + BOOL flutterEvent = + [type isEqualToString:@"task.done"] || + [type isEqualToString:@"task.error"]; + + if (!flutterEvent) { + return; + } + FlutterMethodChannel *channel = self.channel; + dispatch_async(dispatch_get_main_queue(), ^{ - [channel invokeMethod:@"taskEvent" arguments:arguments]; + [channel invokeMethod:@"taskEvent" + arguments:arguments]; }); } @@ -33,3 +163,38 @@ void GopeedSubscribeTaskEventsWithForwarder( GopeedTaskEventForwarder * _Nullable listener) { LibgopeedSubscribeTaskEvents(mask, listener); } + +void GopeedInvokeAsyncWithResult( + NSString *method, + NSString *path, + NSString *query, + NSString *body, + int64_t requestID, + FlutterResult result) { + GopeedInvokeResultForwarder *forwarder = + [[GopeedInvokeResultForwarder alloc] initWithResult:result]; + LibgopeedInvokeAsync(method, path, query, body, requestID, forwarder); +} + +void GopeedInvokeAsyncNative( + NSString *method, + NSString *path, + NSString *query, + NSString *body, + GopeedNativeInvokeCompletion completion) { + if (completion == nil) { + return; + } + + GopeedNativeInvokeResultForwarder *forwarder = + [[GopeedNativeInvokeResultForwarder alloc] + initWithCompletion:completion]; + + LibgopeedInvokeAsync( + method, + path, + query, + body, + 0, + forwarder); +} diff --git a/ui/flutter/ios/Runner/Info.plist b/ui/flutter/ios/Runner/Info.plist index fd2ce83a..61892b05 100644 --- a/ui/flutter/ios/Runner/Info.plist +++ b/ui/flutter/ios/Runner/Info.plist @@ -24,6 +24,9 @@ ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + + FlutterDeepLinkingEnabled + CFBundleURLTypes @@ -112,6 +115,7 @@ UIBackgroundModes location + processing UIApplicationSceneManifest @@ -153,5 +157,14 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSSupportsLiveActivities + + + NSSupportsLiveActivitiesFrequentUpdates + + BGTaskSchedulerPermittedIdentifiers + + $(PRODUCT_BUNDLE_IDENTIFIER).continuedDownload.* + diff --git a/ui/flutter/ios/Runner/SceneDelegate.swift b/ui/flutter/ios/Runner/SceneDelegate.swift index b9ce8ea2..8b34f6cb 100644 --- a/ui/flutter/ios/Runner/SceneDelegate.swift +++ b/ui/flutter/ios/Runner/SceneDelegate.swift @@ -1,6 +1,27 @@ import Flutter import UIKit +import app_links class SceneDelegate: FlutterSceneDelegate { + override func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + super.scene(scene, willConnectTo: session, options: connectionOptions) + forwardGopeedLinks(connectionOptions.urlContexts) + } + override func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + super.scene(scene, openURLContexts: URLContexts) + forwardGopeedLinks(URLContexts) + } + + private func forwardGopeedLinks(_ contexts: Set) { + // app_links 6.x only registers AppDelegate callbacks. UIScene delivers + // both cold-start and running-app scheme links here instead. + for context in contexts where context.url.scheme?.lowercased() == "gopeed" { + AppLinks.shared.handleLink(url: context.url) + } + } } diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart index 39b4bbee..dae6a103 100644 --- a/ui/flutter/lib/api/model/downloader_config.dart +++ b/ui/flutter/lib/api/model/downloader_config.dart @@ -56,6 +56,7 @@ class ProtocolConfig { HttpConfig http = HttpConfig(); BtConfig bt = BtConfig(); Ed2kConfig ed2k = Ed2kConfig(); + HlsConfig hls = HlsConfig(); ProtocolConfig(); @@ -65,6 +66,25 @@ class ProtocolConfig { Map toJson() => _$ProtocolConfigToJson(this); } +@JsonSerializable() +class HlsConfig { + int segmentConnections; + int maxRetries; + int timeoutSeconds; + bool prefetchContentLength; + + HlsConfig({ + this.segmentConnections = 0, + this.maxRetries = 0, + this.timeoutSeconds = 0, + this.prefetchContentLength = false, + }); + + factory HlsConfig.fromJson(Map json) => _$HlsConfigFromJson(json); + + Map toJson() => _$HlsConfigToJson(this); +} + @JsonSerializable() class HttpConfig { String userAgent; @@ -125,6 +145,7 @@ class ExtraConfig { bool notifyWhenNewVersion; bool desktopNotification; bool backgroundLocationKeepAlive; + bool backgroundContinuedProcessing; WindowStateConfig windowState; Map bookmarks; List createHistory; @@ -146,6 +167,7 @@ class ExtraConfig { this.notifyWhenNewVersion = true, this.desktopNotification = true, this.backgroundLocationKeepAlive = false, + this.backgroundContinuedProcessing = false, WindowStateConfig? windowState, this.bookmarks = const {}, this.createHistory = const [], diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart index 9c82c3ce..2ee3c2d8 100644 --- a/ui/flutter/lib/api/model/downloader_config.g.dart +++ b/ui/flutter/lib/api/model/downloader_config.g.dart @@ -56,12 +56,28 @@ Map _$ApiServerConfigToJson(ApiServerConfig instance) => json) => ProtocolConfig() ..http = HttpConfig.fromJson(json['http'] as Map) ..bt = BtConfig.fromJson(json['bt'] as Map) - ..ed2k = Ed2kConfig.fromJson(json['ed2k'] as Map); + ..ed2k = Ed2kConfig.fromJson(json['ed2k'] as Map) + ..hls = HlsConfig.fromJson(json['hls'] as Map); Map _$ProtocolConfigToJson(ProtocolConfig instance) => { 'http': instance.http.toJson(), 'bt': instance.bt.toJson(), 'ed2k': instance.ed2k.toJson(), + 'hls': instance.hls.toJson(), +}; + +HlsConfig _$HlsConfigFromJson(Map json) => HlsConfig( + segmentConnections: (json['segmentConnections'] as num?)?.toInt() ?? 0, + maxRetries: (json['maxRetries'] as num?)?.toInt() ?? 0, + timeoutSeconds: (json['timeoutSeconds'] as num?)?.toInt() ?? 0, + prefetchContentLength: json['prefetchContentLength'] as bool? ?? false, +); + +Map _$HlsConfigToJson(HlsConfig instance) => { + 'segmentConnections': instance.segmentConnections, + 'maxRetries': instance.maxRetries, + 'timeoutSeconds': instance.timeoutSeconds, + 'prefetchContentLength': instance.prefetchContentLength, }; HttpConfig _$HttpConfigFromJson(Map json) => HttpConfig( @@ -119,6 +135,10 @@ ExtraConfig _$ExtraConfigFromJson(Map json) => notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true, desktopNotification: json['desktopNotification'] as bool? ?? true, backgroundLocationKeepAlive: json['backgroundLocationKeepAlive'] as bool? ?? false, + backgroundContinuedProcessing: + json['backgroundContinuedProcessing'] + as bool? ?? + false, windowState: json['windowState'] == null ? null : WindowStateConfig.fromJson(json['windowState'] as Map?), @@ -146,6 +166,8 @@ Map _$ExtraConfigToJson(ExtraConfig instance) => json) => Progress( used: (json['used'] as num).toInt(), diff --git a/ui/flutter/lib/api/model/task_stats.dart b/ui/flutter/lib/api/model/task_stats.dart index 4dafd0b0..dfe6c8c7 100644 --- a/ui/flutter/lib/api/model/task_stats.dart +++ b/ui/flutter/lib/api/model/task_stats.dart @@ -55,7 +55,7 @@ sealed class TaskStats { Protocol.http => HttpTaskStats.fromJson(snapshot), Protocol.bt => BtTaskStats.fromJson(snapshot: snapshot, runtime: runtime), Protocol.ed2k => Ed2kTaskStats.fromJson(snapshot: snapshot, runtime: runtime), - null => null, + Protocol.hls || null => null, }; } } diff --git a/ui/flutter/lib/app/app.dart b/ui/flutter/lib/app/app.dart index d93a404e..33531d28 100644 --- a/ui/flutter/lib/app/app.dart +++ b/ui/flutter/lib/app/app.dart @@ -78,7 +78,8 @@ class _GopeedAppState extends ConsumerState with WidgetsBindingObserv return shad.ShadcnApp.router( debugShowCheckedModeBanner: false, - onGenerateTitle: (context) => context.l10n.appTitle, + disableBrowserContextMenu: false, + title: 'Gopeed', theme: AppTheme.light(themeColor), darkTheme: AppTheme.dark(themeColor), materialTheme: isLightTheme ? AppTheme.materialLight(themeColor) : AppTheme.materialDark(themeColor), diff --git a/ui/flutter/lib/app/application/android_foreground_service.dart b/ui/flutter/lib/app/application/android_foreground_service.dart index 479b7363..9efd4da8 100644 --- a/ui/flutter/lib/app/application/android_foreground_service.dart +++ b/ui/flutter/lib/app/application/android_foreground_service.dart @@ -28,6 +28,13 @@ class AndroidForegroundService { ), ); + // Android 13+ requires runtime permission to show the service notification. + // A denial only hides it from the notification drawer; the service can run. + final permission = await FlutterForegroundTask.checkNotificationPermission(); + if (permission == NotificationPermission.denied) { + await FlutterForegroundTask.requestNotificationPermission(); + } + if (await FlutterForegroundTask.isRunningService) { _throwOnFailure(await FlutterForegroundTask.restartService()); return; diff --git a/ui/flutter/lib/app/application/app_deep_link_controller.dart b/ui/flutter/lib/app/application/app_deep_link_controller.dart index c63ca5df..f960dcbd 100644 --- a/ui/flutter/lib/app/application/app_deep_link_controller.dart +++ b/ui/flutter/lib/app/application/app_deep_link_controller.dart @@ -37,10 +37,15 @@ class AppDeepLinkController extends AsyncNotifier { @override Future build() async { - final runtime = ref.watch(appRuntimeControllerProvider).value; - if (runtime == null || _started || kIsWeb) { + if (_started || kIsWeb) { return AppDeepLinkState(started: _started); } + // Deep-link handling depends on the runtime being ready, but it must not + // rebuild whenever runtime settings change. A rebuild disposes the active + // platform subscriptions while `_started` remains true, leaving Android + // unable to receive later browser takeover intents (for example after the + // API server is started or stopped from Settings). + await ref.read(appRuntimeControllerProvider.future); _started = true; final appLinks = AppLinks(); _subscription = appLinks.uriLinkStream.listen((uri) { @@ -139,7 +144,7 @@ class AppDeepLinkController extends AsyncNotifier { Map _decodeParams(String params) { final safeParams = params.replaceAll('"', '').replaceAll(' ', '+'); - final paramsJson = String.fromCharCodes(base64Decode(base64.normalize(safeParams))); + final paramsJson = utf8.decode(base64Decode(base64.normalize(safeParams))); return jsonDecode(paramsJson) as Map; } diff --git a/ui/flutter/lib/app/application/app_notification_controller.dart b/ui/flutter/lib/app/application/app_notification_controller.dart index 5ca4efb8..cf8619cb 100644 --- a/ui/flutter/lib/app/application/app_notification_controller.dart +++ b/ui/flutter/lib/app/application/app_notification_controller.dart @@ -2,14 +2,14 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; import '../../core/common/task_event.dart'; import '../../core/libgopeed_boot.dart'; import '../../l10n/l10n.dart'; +import '../../util/log_util.dart'; import '../../util/util.dart'; import 'app_runtime_controller.dart'; @@ -28,13 +28,19 @@ class AppNotificationController extends AsyncNotifier { StreamSubscription? _taskEventSubscription; var _notificationId = 0; + @visibleForTesting + Stream get taskEvents => LibgopeedBoot.instance.taskEvents; + @override Future build() async { final runtime = ref.watch(appRuntimeControllerProvider).value; if (runtime == null || kIsWeb || !Util.isDesktop()) { return const AppNotificationState(); } - await _initNotifications(appLocalizationsFor(runtime.downloaderConfig.extra.locale)); + await _initNotifications( + appLocalizationsFor(runtime.downloaderConfig.extra.locale), + requestPermissions: runtime.downloaderConfig.extra.desktopNotification, + ); _listenTaskEvents(); ref.onDispose(() { unawaited(_taskEventSubscription?.cancel()); @@ -42,11 +48,13 @@ class AppNotificationController extends AsyncNotifier { return const AppNotificationState(started: true); } - Future _initNotifications(AppLocalizations locale) async { - const darwin = DarwinInitializationSettings( - requestAlertPermission: false, + Future _initNotifications(AppLocalizations locale, {required bool requestPermissions}) async { + // The runtime is watched, so enabling notifications in settings also + // requests authorization. macOS remembers previously granted/denied access. + final darwin = DarwinInitializationSettings( + requestAlertPermission: requestPermissions, requestBadgePermission: false, - requestSoundPermission: false, + requestSoundPermission: requestPermissions, ); final linux = LinuxInitializationSettings( defaultActionName: locale.open, @@ -56,13 +64,27 @@ class AppNotificationController extends AsyncNotifier { String? windowsIconPath; try { if (Util.isWindows()) { - final byteData = await rootBundle.load('assets/icon/icon.ico'); - final tempDir = await getTemporaryDirectory(); - final file = File('${tempDir.path}/notification_icon.ico'); - await file.writeAsBytes(byteData.buffer.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes)); - windowsIconPath = file.path; + // Windows requires a file path for IconUri, so use the bundled PNG + // beside the executable rather than copying it to another directory. + final file = File( + path.join( + path.dirname(Platform.resolvedExecutable), + 'data', + 'flutter_assets', + 'assets', + 'icon', + 'icon_512.png', + ), + ); + if (await file.exists()) { + windowsIconPath = file.absolute.path; + } else { + logger.w('Windows notification icon not found: ${file.path}'); + } } - } catch (_) {} + } catch (error, stackTrace) { + logger.w('prepare Windows notification icon failed', error, stackTrace); + } final windows = WindowsInitializationSettings( appName: 'Gopeed', @@ -78,7 +100,7 @@ class AppNotificationController extends AsyncNotifier { void _listenTaskEvents() { _taskEventSubscription?.cancel(); - _taskEventSubscription = LibgopeedBoot.instance.taskEvents.listen((event) async { + _taskEventSubscription = taskEvents.listen((event) async { final runtime = ref.read(appRuntimeControllerProvider).value; if (runtime?.downloaderConfig.extra.desktopNotification == false) return; final locale = appLocalizationsFor(runtime?.downloaderConfig.extra.locale ?? ''); diff --git a/ui/flutter/lib/app/application/app_platform_controller.dart b/ui/flutter/lib/app/application/app_platform_controller.dart index 53e542d1..1d94d134 100644 --- a/ui/flutter/lib/app/application/app_platform_controller.dart +++ b/ui/flutter/lib/app/application/app_platform_controller.dart @@ -54,6 +54,9 @@ class AppPlatformState { enum AppUpdateStatus { idle, checking, upToDate, available, failed } class AppPlatformController extends AsyncNotifier with WindowListener, TrayListener { + @protected + bool get isDesktop => Util.isDesktop(); + bool _started = false; String? _trayLocaleConfig; bool _updateInitialized = false; @@ -74,30 +77,54 @@ class AppPlatformController extends AsyncNotifier with WindowL @override Future build() async { - final runtime = ref.watch(appRuntimeControllerProvider).value; - if (runtime == null || kIsWeb) { - return _snapshot(); - } - if (!Util.isDesktop()) { - _scheduleInitialUpdateCheck(); - return _snapshot(); - } - if (_started) { - final localeConfig = runtime.downloaderConfig.extra.locale; - if (_trayLocaleConfig != localeConfig) await _initTray(); - return _snapshot(); - } - await _start(); - _scheduleInitialUpdateCheck(); + ref.listen(appRuntimeControllerProvider, (_, next) { + unawaited( + _handleRuntimeChange(next.value).catchError((Object error, StackTrace stackTrace) { + if (ref.mounted) state = AsyncValue.error(error, stackTrace); + }), + ); + }); ref.onDispose(() { + if (!_started) return; + _started = false; windowManager.removeListener(this); trayManager.removeListener(this); unawaited(HostRpcService.instance.stop()); unawaited(AppWindowCapabilityHost.instance.stop()); }); + final runtime = ref.read(appRuntimeControllerProvider).value; + if (runtime == null || kIsWeb) { + return _snapshot(); + } + if (!isDesktop) { + _scheduleInitialUpdateCheck(); + return _snapshot(); + } + if (_started) { + return _snapshot(); + } + await _start(); + _scheduleInitialUpdateCheck(); return _snapshot(); } + Future _handleRuntimeChange(AppRuntimeState? runtime) async { + if (runtime == null || kIsWeb) return; + if (!isDesktop) { + _scheduleInitialUpdateCheck(); + return; + } + if (!_started) { + await _start(); + _scheduleInitialUpdateCheck(); + _emit(); + return; + } + if (_trayLocaleConfig != runtime.downloaderConfig.extra.locale) { + await _initTray(); + } + } + void _scheduleInitialUpdateCheck() { if (_updateInitialized) return; _updateInitialized = true; diff --git a/ui/flutter/lib/app/application/app_runtime_controller.dart b/ui/flutter/lib/app/application/app_runtime_controller.dart index 76dedd3e..41ee09a2 100644 --- a/ui/flutter/lib/app/application/app_runtime_controller.dart +++ b/ui/flutter/lib/app/application/app_runtime_controller.dart @@ -23,6 +23,7 @@ import '../../util/util.dart'; import '../rpc/webview_rpc_service.dart'; import 'android_foreground_service.dart'; import 'location_keep_alive.dart'; +import 'continued_processing.dart'; const unixSocketPath = 'gopeed.sock'; @@ -105,8 +106,14 @@ class AppRuntimeController extends AsyncNotifier { ), ); unawaited(LocationKeepAliveCoordinator.instance.reconcile(enabled: config.extra.backgroundLocationKeepAlive)); + unawaited( + ContinuedProcessing.setEnabled( + config.extra.backgroundContinuedProcessing, + ), + ); } + Future _init() async { await AppInitializer.ensureStorageInitialized(); try { @@ -180,6 +187,13 @@ class AppRuntimeController extends AsyncNotifier { startupError: startupError, ); if (Util.isIOS()) { + // Make sure the native Continued Processing manager has the + // persisted setting before app initialization completes. This + // avoids the first download racing ahead of setEnabled(true). + await ContinuedProcessing.setEnabled( + config.extra.backgroundContinuedProcessing, + ); + LocationKeepAliveCoordinator.instance.start( () => state.value?.downloaderConfig.extra.backgroundLocationKeepAlive ?? config.extra.backgroundLocationKeepAlive, diff --git a/ui/flutter/lib/app/application/continued_processing.dart b/ui/flutter/lib/app/application/continued_processing.dart new file mode 100644 index 00000000..08bec054 --- /dev/null +++ b/ui/flutter/lib/app/application/continued_processing.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +class ContinuedProcessing { + const ContinuedProcessing._(); + + static const _channel = + MethodChannel('gopeed/continued_processing'); + + static Future isSupported() async { + if (!Platform.isIOS) return false; + + try { + return await _channel.invokeMethod( + 'isSupported', + ) ?? + false; + } catch (_) { + return false; + } + } + + static Future setEnabled( + bool enabled, + ) async { + if (!Platform.isIOS) return false; + + try { + return await _channel.invokeMethod( + 'setEnabled', + {'enabled': enabled}, + ) ?? + false; + } catch (_) { + return false; + } + } +} diff --git a/ui/flutter/lib/app/router/app_router.dart b/ui/flutter/lib/app/router/app_router.dart index 29a1d9a9..c1d7ee62 100644 --- a/ui/flutter/lib/app/router/app_router.dart +++ b/ui/flutter/lib/app/router/app_router.dart @@ -1,5 +1,8 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../features/tasks/application/task_list_navigation.dart'; import '../../features/extensions/presentation/pages/extensions_page.dart'; import '../../features/extensions/presentation/pages/extension_details_page.dart'; @@ -13,11 +16,50 @@ import '../../features/tasks/domain/task_record.dart'; import '../../features/tasks/presentation/pages/task_details_page.dart'; import '../../features/tasks/presentation/pages/task_files_page.dart'; import 'shells/main_shell.dart'; +import 'mobile_exit_guard.dart'; +import 'runtime_page.dart'; class AppRouter { static final GlobalKey rootNavigatorKey = GlobalKey(); + static void showDownloadingTasks() { + final context = rootNavigatorKey.currentContext; + if (context == null || !context.mounted) return; + ProviderScope.containerOf(context, listen: false).read(taskListNavigationProvider.notifier).showDownloading(); + GoRouter.of(context).go('/'); + } + static GoRouter build(WebAuthController webAuthController) { + // Keep desktop and web sections as peers; native mobile retains home below them. + final isMobile = + !kIsWeb && (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS); + final sectionRoutes = [ + GoRoute( + path: isMobile ? 'extensions' : '/extensions', + builder: _runtimePage((context, state) => const ExtensionsPage()), + routes: [ + GoRoute( + path: ':id', + builder: _runtimePage( + (context, state) => ExtensionDetailsPage( + extensionId: state.pathParameters['id'] ?? '', + initialItem: state.extra is ExtensionListItem ? state.extra as ExtensionListItem : null, + ), + ), + ), + ], + ), + GoRoute( + path: isMobile ? 'settings' : '/settings', + builder: _runtimePage((context, state) => const SettingsPage()), + routes: [ + GoRoute( + path: ':section', + builder: _runtimePage((context, state) => SettingsPage(sectionKey: state.pathParameters['section'])), + ), + ], + ), + ]; return GoRouter( navigatorKey: rootNavigatorKey, refreshListenable: webAuthController, @@ -35,60 +77,47 @@ class AppRouter { routes: [ GoRoute(path: '/login', parentNavigatorKey: rootNavigatorKey, builder: (context, state) => const LoginPage()), ShellRoute( - builder: (context, state, child) => MainShell(child: child), + builder: (context, state, child) => MobileExitGuard(child: MainShell(child: child)), routes: [ GoRoute( path: '/', - builder: (context, state) => const HomePage(), + builder: _runtimePage((context, state) => const HomePage()), routes: [ - GoRoute(path: 'create', builder: (context, state) => const CreateTaskWindowPage()), + GoRoute(path: 'create', builder: _runtimePage((context, state) => const CreateTaskWindowPage())), GoRoute( path: 'tasks/:id', - builder: (context, state) => TaskDetailsPage( - taskId: state.pathParameters['id'] ?? '', - initialTask: state.extra is TaskRecord ? state.extra as TaskRecord : null, + builder: _runtimePage( + (context, state) => TaskDetailsPage( + taskId: state.pathParameters['id'] ?? '', + initialTask: state.extra is TaskRecord ? state.extra as TaskRecord : null, + ), ), routes: [ GoRoute( path: 'files', - builder: (context, state) => TaskFilesPage( - taskId: state.pathParameters['id'] ?? '', - initialTask: state.extra is TaskRecord ? state.extra as TaskRecord : null, + builder: _runtimePage( + (context, state) => TaskFilesPage( + taskId: state.pathParameters['id'] ?? '', + initialTask: state.extra is TaskRecord ? state.extra as TaskRecord : null, + ), ), ), ], ), + if (isMobile) ...sectionRoutes, ], ), - GoRoute( - path: '/extensions', - builder: (context, state) => const ExtensionsPage(), - routes: [ - GoRoute( - path: ':id', - builder: (context, state) => ExtensionDetailsPage( - extensionId: state.pathParameters['id'] ?? '', - initialItem: state.extra is ExtensionListItem ? state.extra as ExtensionListItem : null, - ), - ), - ], - ), - GoRoute( - path: '/settings', - builder: (context, state) => const SettingsPage(), - routes: [ - GoRoute( - path: ':section', - builder: (context, state) => SettingsPage(sectionKey: state.pathParameters['section']), - ), - ], - ), + if (!isMobile) ...sectionRoutes, ], ), ], ); } + static GoRouterWidgetBuilder _runtimePage(GoRouterWidgetBuilder builder) { + return (context, state) => RuntimePage(builder: (context) => builder(context, state)); + } + static String _safeReturnLocation(String? location) { if (location == null || location.isEmpty) return '/'; final uri = Uri.tryParse(location); diff --git a/ui/flutter/lib/app/router/mobile_exit_guard.dart b/ui/flutter/lib/app/router/mobile_exit_guard.dart new file mode 100644 index 00000000..5f792dbc --- /dev/null +++ b/ui/flutter/lib/app/router/mobile_exit_guard.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../../shared/navigation/app_exit_confirmation_controller.dart'; + +/// Handles exit at the shell route after the nested Navigator declines a pop. +/// Child routes and dialogs still pop through GoRouter before reaching this guard. +class MobileExitGuard extends StatefulWidget { + const MobileExitGuard({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _MobileExitGuardState(); +} + +class _MobileExitGuardState extends State with WidgetsBindingObserver { + final _controller = AppExitConfirmationController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) _controller.dispose(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return widget.child; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) unawaited(_controller.handleBack(context)); + }, + child: NotificationListener( + onNotification: (notification) { + if (notification.canHandlePop) return false; + // An empty nested back stack still has this shell's exit guard. + // Otherwise its startup notification disables Android's back callback. + const NavigationNotification(canHandlePop: true).dispatch(context); + return true; + }, + child: widget.child, + ), + ); + } +} diff --git a/ui/flutter/lib/app/router/runtime_page.dart b/ui/flutter/lib/app/router/runtime_page.dart new file mode 100644 index 00000000..e63fdfed --- /dev/null +++ b/ui/flutter/lib/app/router/runtime_page.dart @@ -0,0 +1,30 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../application/app_runtime_controller.dart'; +import '../../l10n/l10n.dart'; + +/// Gates page content without unmounting the Navigator during startup. +class RuntimePage extends ConsumerWidget { + const RuntimePage({super.key, required this.builder}); + + final WidgetBuilder builder; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ref + .watch(appRuntimeControllerProvider) + .when( + loading: () => const Scaffold(child: Center(child: CircularProgressIndicator())), + error: (error, _) => Scaffold( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Text(context.l10n.runtimeInitializationFailed(error.toString()), textAlign: TextAlign.center), + ), + ), + ), + data: (_) => builder(context), + ); + } +} diff --git a/ui/flutter/lib/app/router/shells/main_shell.dart b/ui/flutter/lib/app/router/shells/main_shell.dart index 7b5e560c..4af3f1de 100644 --- a/ui/flutter/lib/app/router/shells/main_shell.dart +++ b/ui/flutter/lib/app/router/shells/main_shell.dart @@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import '../../../api/model/create_task.dart'; import '../../application/app_deep_link_controller.dart'; @@ -16,10 +15,7 @@ import '../../../features/tasks/application/pending_create_task.dart'; import '../../../features/tasks/application/pending_update_task.dart'; import '../../../features/tasks/application/tasks_controller.dart'; import '../../../features/tasks/presentation/widgets/pending_update_dialog.dart'; -import '../../../l10n/l10n.dart'; import '../../../core/window/app_window_launcher.dart'; -import '../../../core/utils/breakpoints.dart'; -import '../../../shared/navigation/app_exit_confirmation_controller.dart'; import '../../../shared/widgets/app_toast.dart'; class MainShell extends ConsumerStatefulWidget { @@ -34,7 +30,6 @@ class MainShell extends ConsumerStatefulWidget { class _MainShellState extends ConsumerState { String? _shownUpdateVersion; bool _handlingPendingUpdateRequest = false; - final _exitConfirmationController = AppExitConfirmationController(); @override Widget build(BuildContext context) { @@ -65,40 +60,13 @@ class _MainShellState extends ConsumerState { } }); final runtime = ref.watch(appRuntimeControllerProvider); - final content = runtime.when( - loading: () => const shad.Scaffold(child: Center(child: shad.CircularProgressIndicator())), - error: (error, _) => shad.Scaffold( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Text(context.l10n.runtimeInitializationFailed(error.toString()), textAlign: TextAlign.center), - ), - ), - ), - data: (_) { - ref.watch(appPlatformControllerProvider); - ref.watch(appDeepLinkControllerProvider); - ref.watch(appNotificationControllerProvider); - return widget.child; - }, - ); - return _buildMobileBackGuard(context, content); - } - - Widget _buildMobileBackGuard(BuildContext context, Widget child) { - final isAndroidMobile = - !kIsWeb && - defaultTargetPlatform == TargetPlatform.android && - MediaQuery.sizeOf(context).width < Breakpoints.mobile; - if (!isAndroidMobile || context.canPop()) return child; - - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) { - if (!didPop) unawaited(_exitConfirmationController.handleBack(context)); - }, - child: child, - ); + if (runtime.hasValue) { + ref.watch(appPlatformControllerProvider); + ref.watch(appDeepLinkControllerProvider); + ref.watch(appNotificationControllerProvider); + } + // Keep the nested Navigator mounted even while the runtime is starting. + return widget.child; } void _schedulePendingUpdateRequest(PendingUpdateRequest request) { diff --git a/ui/flutter/lib/app/rpc/webview_rpc_service.dart b/ui/flutter/lib/app/rpc/webview_rpc_service.dart index 653e78e6..b138abd0 100644 --- a/ui/flutter/lib/app/rpc/webview_rpc_service.dart +++ b/ui/flutter/lib/app/rpc/webview_rpc_service.dart @@ -3,6 +3,7 @@ import 'dart:collection'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import '../../core/common/start_config.dart'; @@ -73,7 +74,6 @@ class WebViewRpcService { final Map _pagesById = {}; final Completer _overlayReady = Completer(); - late final CookieManager _cookieManager = CookieManager.instance(); RpcServerHandle? _server; int _pageSeq = 0; @@ -129,6 +129,9 @@ class WebViewRpcService { switch (method) { case 'webview.isAvailable': return {'available': true}; + case 'profile.remove': + await _removeProfile(_string(params, 'profileId')); + return {}; case 'page.open': return _openPage(params); case 'page.addInitScript': @@ -162,10 +165,12 @@ class WebViewRpcService { } Future> _openPage(Map params) async { + final profile = WebViewProfile(_string(params, 'profileId')); + await profile.prepare(proxyUrl: Uri.parse(_string(params, 'proxyUrl'))); final pageId = 'page-${++_pageSeq}'; final session = WebViewRpcPageSession( pageId: pageId, - cookieManager: _cookieManager, + profile: profile, headless: params['headless'] as bool? ?? false, debug: params['debug'] as bool? ?? false, title: params['title'] as String? ?? '', @@ -185,6 +190,15 @@ class WebViewRpcService { return {'pageId': pageId}; } + Future _removeProfile(String id) async { + for (final page in _pagesById.values.where((page) => page.profile.id == id).toList()) { + await _closePage(page.pageId); + } + // Let visible platform views unmount before releasing their native store. + await WidgetsBinding.instance.endOfFrame; + await WebViewProfile(id).remove(); + } + WebViewRpcPageSession _page(Map params) { final page = _pagesById[_pageId(params)]; if (page == null) { @@ -271,6 +285,9 @@ class WebViewRpcService { if (error is WebViewRpcException) { return {'code': error.code, 'message': error.message}; } + if (error is PlatformException && error.code == 'UNAVAILABLE') { + return {'code': error.code, 'message': error.message ?? 'WebView is unavailable'}; + } final code = switch (method) { 'page.goto' => 'NAVIGATION_FAILED', 'page.execute' => 'EVALUATION_FAILED', @@ -283,7 +300,7 @@ class WebViewRpcService { class WebViewRpcPageSession { WebViewRpcPageSession({ required this.pageId, - required this.cookieManager, + required this.profile, required this.headless, required this.debug, required this.title, @@ -293,7 +310,8 @@ class WebViewRpcPageSession { }); final String pageId; - final CookieManager cookieManager; + final WebViewProfile profile; + late final CookieManager cookieManager = profile.cookieManager; final bool headless; final bool debug; final String title; @@ -449,6 +467,8 @@ class WebViewRpcPageSession { 'path': cookie.path ?? '/', 'secure': cookie.isSecure, 'httpOnly': cookie.isHttpOnly, + if (cookie.expiresDate != null) + 'expires': DateTime.fromMillisecondsSinceEpoch(cookie.expiresDate!, isUtc: true).toIso8601String(), }; }) .toList(growable: false); @@ -524,6 +544,7 @@ class WebViewRpcPageSession { } InAppWebViewSettings get _settings => InAppWebViewSettings( + profileId: profile.id, javaScriptEnabled: true, transparentBackground: true, isInspectable: debug, diff --git a/ui/flutter/lib/core/capabilities/app_capabilities.dart b/ui/flutter/lib/core/capabilities/app_capabilities.dart index 6333ea5f..106a42c7 100644 --- a/ui/flutter/lib/core/capabilities/app_capabilities.dart +++ b/ui/flutter/lib/core/capabilities/app_capabilities.dart @@ -16,12 +16,18 @@ import '../window/app_window_appearance.dart'; import 'capability_rpc.dart'; import 'gopeed_capability.dart'; import 'storage_capability.dart'; +import 'app_navigation_capability.dart'; +import '../../app/router/app_router.dart'; class AppCapabilities { - AppCapabilities(CapabilityInvoker invoker) : gopeed = GopeedService(invoker), storage = AppStorageService(invoker); + AppCapabilities(CapabilityInvoker invoker) + : gopeed = GopeedService(invoker), + storage = AppStorageService(invoker), + navigation = AppNavigationService(invoker); final GopeedService gopeed; final AppStorageService storage; + final AppNavigationService navigation; } class LocalAppCapabilities { @@ -29,6 +35,10 @@ class LocalAppCapabilities { registry = CapabilityRegistry(codecs); _bindGopeed(registry); _bindStorage(registry); + registry.bind(NavigationMethods.showDownloadingTasks, (_) { + AppRouter.showDownloadingTasks(); + return const RpcUnit(); + }); capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); } diff --git a/ui/flutter/lib/core/capabilities/app_navigation_capability.dart b/ui/flutter/lib/core/capabilities/app_navigation_capability.dart new file mode 100644 index 00000000..f1b18ccd --- /dev/null +++ b/ui/flutter/lib/core/capabilities/app_navigation_capability.dart @@ -0,0 +1,15 @@ +import 'capability_rpc.dart'; + +abstract final class NavigationMethods { + static const showDownloadingTasks = RpcMethod('navigation.tasks.downloading'); +} + +class AppNavigationService { + const AppNavigationService(this._invoker); + + final CapabilityInvoker _invoker; + + Future showDownloadingTasks() async { + await _invoker.invoke(NavigationMethods.showDownloadingTasks, const RpcUnit()); + } +} diff --git a/ui/flutter/lib/core/common/libgopeed_channel.dart b/ui/flutter/lib/core/common/libgopeed_channel.dart index 7bc32778..a6e03c46 100644 --- a/ui/flutter/lib/core/common/libgopeed_channel.dart +++ b/ui/flutter/lib/core/common/libgopeed_channel.dart @@ -11,6 +11,7 @@ import 'task_event.dart'; class LibgopeedChannel implements LibgopeedInterface { static const _channel = MethodChannel('gopeed.com/libgopeed'); final _taskEvents = StreamController.broadcast(); + int _requestID = 0; LibgopeedChannel() { _channel.setMethodCallHandler((call) async { @@ -50,7 +51,9 @@ class LibgopeedChannel implements LibgopeedInterface { @override Future invoke(String method, String path, {String query = '', String body = ''}) async { + final requestID = _requestID++; final result = await _channel.invokeMethod('invoke', { + 'requestID': requestID, 'method': method, 'path': path, 'query': query, diff --git a/ui/flutter/lib/core/common/libgopeed_ffi.dart b/ui/flutter/lib/core/common/libgopeed_ffi.dart index aae4b938..7081f34f 100644 --- a/ui/flutter/lib/core/common/libgopeed_ffi.dart +++ b/ui/flutter/lib/core/common/libgopeed_ffi.dart @@ -1,10 +1,6 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:ffi'; -import 'package:ffi/ffi.dart'; - -import '../ffi/libgopeed_bind.dart'; import '../ffi/libgopeed_worker.dart'; import 'api_server_state.dart'; import 'libgopeed_interface.dart'; @@ -12,51 +8,22 @@ import 'start_config.dart'; import 'task_event.dart'; class LibgopeedFFi implements LibgopeedInterface { - late LibgopeedBind _libgopeed; + LibgopeedFFi() { + _worker.taskEvents.listen(_onTaskEvent, onError: _taskEvents.addError); + } + final _taskEvents = StreamController.broadcast(); final _worker = LibgopeedWorker(); - late final NativeCallable)> _taskEventCallback; - LibgopeedFFi(LibgopeedBind libgopeed) { - _libgopeed = libgopeed; - _taskEventCallback = NativeCallable)>.listener(_onTaskEvent); - } - - void _onTaskEvent(Pointer payload) { - try { - final decoded = jsonDecode(payload.cast().toDartString()) as Map; - _taskEvents.add(TaskEvent.fromJson(decoded)); - } finally { - _libgopeed.FreeCString(payload); - } + void _onTaskEvent(Map payload) { + _taskEvents.add(TaskEvent.fromJson(payload)); } @override - Future start(StartConfig cfg) { - var completer = Completer(); - final cfgPtr = jsonEncode(cfg).toNativeUtf8(); - try { - final result = _libgopeed.Start(cfgPtr.cast()); - if (result.r1 != nullptr) { - final message = result.r1.cast().toDartString(); - _libgopeed.FreeCString(result.r1); - completer.completeError(Exception(message)); - } else { - completer.complete(result.r0); - } - } finally { - malloc.free(cfgPtr); - } - return completer.future; - } + Future start(StartConfig cfg) => _worker.start(jsonEncode(cfg)); @override - Future stop() { - var completer = Completer(); - _libgopeed.Stop(); - completer.complete(); - return completer.future; - } + Future stop() => _worker.stop(); Future _apiServerOperation(String operation) async { final payload = await _worker.apiServer(operation); @@ -77,33 +44,12 @@ class LibgopeedFFi implements LibgopeedInterface { @override Future invoke(String method, String path, {String query = '', String body = ''}) async { - final methodPtr = method.toNativeUtf8(); - final pathPtr = path.toNativeUtf8(); - final queryPtr = query.toNativeUtf8(); - final bodyPtr = body.toNativeUtf8(); - Pointer? resultPtr; - try { - resultPtr = _libgopeed.Invoke(methodPtr.cast(), pathPtr.cast(), queryPtr.cast(), bodyPtr.cast()); - if (resultPtr == nullptr) { - throw StateError('Gopeed Invoke returned a null response'); - } - return resultPtr.cast().toDartString(); - } finally { - malloc.free(methodPtr); - malloc.free(pathPtr); - malloc.free(queryPtr); - malloc.free(bodyPtr); - if (resultPtr != null && resultPtr != nullptr) { - _libgopeed.FreeCString(resultPtr); - } - } + return jsonEncode(await _worker.invoke(method, path, query, body)); } @override Stream get taskEvents => _taskEvents.stream; @override - Future subscribeTaskEvents(Set events) async { - _libgopeed.SubscribeTaskEvents(events.mask, events.isEmpty ? 0 : _taskEventCallback.nativeFunction.address); - } + Future subscribeTaskEvents(Set events) => _worker.subscribeTaskEvents(events.mask); } diff --git a/ui/flutter/lib/core/entry/libgopeed_boot_native.dart b/ui/flutter/lib/core/entry/libgopeed_boot_native.dart index 1077c045..6c3a9e4e 100644 --- a/ui/flutter/lib/core/entry/libgopeed_boot_native.dart +++ b/ui/flutter/lib/core/entry/libgopeed_boot_native.dart @@ -1,6 +1,4 @@ import 'dart:async'; -import 'dart:ffi'; -import 'dart:io'; import '../../util/util.dart'; import '../common/libgopeed_channel.dart'; @@ -9,7 +7,6 @@ import '../common/libgopeed_interface.dart'; import '../common/api_server_state.dart'; import '../common/start_config.dart'; import '../common/task_event.dart'; -import '../ffi/libgopeed_bind.dart'; import '../libgopeed_boot.dart'; LibgopeedBoot create() => LibgopeedBootNative(); @@ -19,17 +16,7 @@ class LibgopeedBootNative implements LibgopeedBoot { LibgopeedBootNative() { if (Util.isDesktop()) { - var libName = "libgopeed."; - if (Platform.isWindows) { - libName += "dll"; - } - if (Platform.isMacOS) { - libName += "dylib"; - } - if (Platform.isLinux) { - libName += "so"; - } - _libgopeed = LibgopeedFFi(LibgopeedBind(DynamicLibrary.open(libName))); + _libgopeed = LibgopeedFFi(); } else { _libgopeed = LibgopeedChannel(); } diff --git a/ui/flutter/lib/core/ffi/libgopeed_bind.dart b/ui/flutter/lib/core/ffi/libgopeed_bind.dart index 347d416b..93e9b2e5 100644 --- a/ui/flutter/lib/core/ffi/libgopeed_bind.dart +++ b/ui/flutter/lib/core/ffi/libgopeed_bind.dart @@ -30,53 +30,67 @@ class LibgopeedBind { late final _StopPtr = _lookup>('Stop'); late final _Stop = _StopPtr.asFunction(); - ffi.Pointer GetAPIServerState() => _GetAPIServerState(); + ffi.Pointer GetAPIServerState() { + return _GetAPIServerState(); + } late final _GetAPIServerStatePtr = _lookup Function()>>('GetAPIServerState'); late final _GetAPIServerState = _GetAPIServerStatePtr.asFunction Function()>(); - ffi.Pointer StartAPIServer() => _StartAPIServer(); + ffi.Pointer StartAPIServer() { + return _StartAPIServer(); + } late final _StartAPIServerPtr = _lookup Function()>>('StartAPIServer'); late final _StartAPIServer = _StartAPIServerPtr.asFunction Function()>(); - ffi.Pointer StopAPIServer() => _StopAPIServer(); + ffi.Pointer StopAPIServer() { + return _StopAPIServer(); + } late final _StopAPIServerPtr = _lookup Function()>>('StopAPIServer'); late final _StopAPIServer = _StopAPIServerPtr.asFunction Function()>(); - ffi.Pointer RestartAPIServer() => _RestartAPIServer(); + ffi.Pointer RestartAPIServer() { + return _RestartAPIServer(); + } late final _RestartAPIServerPtr = _lookup Function()>>('RestartAPIServer'); late final _RestartAPIServer = _RestartAPIServerPtr.asFunction Function()>(); - ffi.Pointer Invoke( + void InvokeAsync( ffi.Pointer method, ffi.Pointer path, ffi.Pointer query, ffi.Pointer body, + int requestID, + int callback, ) { - return _Invoke(method, path, query, body); + return _InvokeAsync(method, path, query, body, requestID, callback); } - late final _InvokePtr = + late final _InvokeAsyncPtr = _lookup< ffi.NativeFunction< - ffi.Pointer Function( + ffi.Void Function( ffi.Pointer, ffi.Pointer, ffi.Pointer, ffi.Pointer, + ffi.UnsignedLongLong, + ffi.UintPtr, ) > - >('Invoke'); - late final _Invoke = - _InvokePtr.asFunction< - ffi.Pointer Function( + >('InvokeAsync'); + late final _InvokeAsync = + _InvokeAsyncPtr.asFunction< + void Function( ffi.Pointer, ffi.Pointer, ffi.Pointer, ffi.Pointer, + int, + int, ) >(); @@ -84,9 +98,8 @@ class LibgopeedBind { return _SubscribeTaskEvents(mask, callback); } - late final _SubscribeTaskEventsPtr = _lookup>( - 'SubscribeTaskEvents', - ); + late final _SubscribeTaskEventsPtr = + _lookup>('SubscribeTaskEvents'); late final _SubscribeTaskEvents = _SubscribeTaskEventsPtr.asFunction(); void FreeCString(ffi.Pointer value) { diff --git a/ui/flutter/lib/core/ffi/libgopeed_worker.dart b/ui/flutter/lib/core/ffi/libgopeed_worker.dart index 72f60912..a056b077 100644 --- a/ui/flutter/lib/core/ffi/libgopeed_worker.dart +++ b/ui/flutter/lib/core/ffi/libgopeed_worker.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:ffi'; import 'dart:io'; import 'dart:isolate'; @@ -7,38 +8,74 @@ import 'package:ffi/ffi.dart'; import 'libgopeed_bind.dart'; +typedef _InvokeResultNative = Void Function(Uint64 requestID, Int32 success, Pointer payload); +typedef _TaskEventNative = Void Function(Pointer payload); + class LibgopeedWorker { - LibgopeedWorker() { + factory LibgopeedWorker() => _instance; + + LibgopeedWorker._() { _receivePort.listen(_handleWorkerMessage); _errorPort.listen(_handleWorkerError); _isolateReady = Isolate.spawn(_workerMain, _receivePort.sendPort, onError: _errorPort.sendPort); } + static final LibgopeedWorker _instance = LibgopeedWorker._(); + final ReceivePort _receivePort = ReceivePort(); final ReceivePort _errorPort = ReceivePort(); - final Map> _pending = {}; + final Map> _pending = {}; + final StreamController> _taskEvents = StreamController>.broadcast(); late final Future _isolateReady; final Completer _sendPort = Completer(); + Future? _stopFuture; int _requestID = 0; + bool _stopping = false; + bool _stopped = false; - Future invoke(String method, String path, String query, String body) async { - await _isolateReady; - final sendPort = await _sendPort.future; - final requestID = _requestID++; - final completer = Completer(); - _pending[requestID] = completer; - sendPort.send(['invoke', requestID, method, path, query, body]); - return completer.future; + Stream> get taskEvents => _taskEvents.stream; + + Future start(String config) => _request('start', [config]); + + Future stop() => _stopFuture ??= _stop(); + + Future _stop() async { + _stopping = true; + // The native bridge owns the bounded drain. Sending Stop immediately is + // important because an InvokeAsync handler such as Resolve may never return. + await _request('stop', const [], allowWhileStopping: true); + _stopped = true; + final stoppingError = StateError('libgopeed worker stopped before the request completed'); + for (final completer in _pending.values) { + completer.completeError(stoppingError); + } + _pending.clear(); + await _taskEvents.close(); + _receivePort.close(); + _errorPort.close(); } - Future apiServer(String operation) async { + Future invoke(String method, String path, String query, String body) { + return _request('invoke', [method, path, query, body]); + } + + Future apiServer(String operation) => _request('apiServer', [operation]); + + Future subscribeTaskEvents(int mask) async { + await _request('subscribeTaskEvents', [mask]); + } + + Future _request(String type, List arguments, {bool allowWhileStopping = false}) async { + if (_stopped || (_stopping && !allowWhileStopping)) { + throw StateError('libgopeed worker is stopping'); + } await _isolateReady; final sendPort = await _sendPort.future; final requestID = _requestID++; - final completer = Completer(); + final completer = Completer(); _pending[requestID] = completer; - sendPort.send(['apiServer', requestID, operation]); - return completer.future; + sendPort.send([type, requestID, ...arguments]); + return (await completer.future) as T; } void _handleWorkerMessage(dynamic message) { @@ -47,13 +84,18 @@ class LibgopeedWorker { return; } final response = message as List; - final requestID = response[0] as int; - final completer = _pending.remove(requestID); - if (completer == null) return; - if (response[1] as bool) { - completer.complete(response[2] as String); - } else { - completer.completeError(StateError(response[2] as String)); + switch (response[0] as String) { + case 'response': + final requestID = response[1] as int; + final completer = _pending.remove(requestID); + if (completer == null) return; + if (response[2] as bool) { + completer.complete(response[3]); + } else { + completer.completeError(StateError(response[3] as String)); + } + case 'taskEvent': + _taskEvents.add((response[1] as Map).cast()); } } @@ -65,35 +107,117 @@ class LibgopeedWorker { completer.completeError(error); } _pending.clear(); + _taskEvents.addError(error); } static void _workerMain(SendPort mainSendPort) { final requests = ReceivePort(); final bindings = LibgopeedBind(DynamicLibrary.open(_libraryName())); + late final NativeCallable<_InvokeResultNative> invokeResultCallback; + late final NativeCallable<_TaskEventNative> taskEventCallback; + + invokeResultCallback = NativeCallable<_InvokeResultNative>.listener(( + int requestID, + int success, + Pointer payload, + ) { + if (payload == nullptr) { + mainSendPort.send(['response', requestID, false, 'Gopeed InvokeAsync returned a null response']); + return; + } + try { + final result = payload.cast().toDartString(); + mainSendPort.send(['response', requestID, success != 0, success != 0 ? jsonDecode(result) : result]); + } catch (error) { + mainSendPort.send(['response', requestID, false, error.toString()]); + } finally { + bindings.FreeCString(payload); + } + }); + + taskEventCallback = NativeCallable<_TaskEventNative>.listener((Pointer payload) { + if (payload == nullptr) return; + try { + final event = jsonDecode(payload.cast().toDartString()) as Map; + mainSendPort.send(['taskEvent', event]); + } finally { + bindings.FreeCString(payload); + } + }); + mainSendPort.send(requests.sendPort); requests.listen((dynamic message) { final request = message as List; final type = request[0] as String; final requestID = request[1] as int; - try { - final response = switch (type) { - 'invoke' => _invoke( + if (type == 'invoke') { + try { + _invokeAsync( bindings, + invokeResultCallback.nativeFunction.address, + requestID, request[2] as String, request[3] as String, request[4] as String, request[5] as String, - ), + ); + } catch (error) { + mainSendPort.send(['response', requestID, false, error.toString()]); + } + return; + } + + try { + final response = switch (type) { + 'start' => _start(bindings, request[2] as String), + 'stop' => _stopNative(bindings), 'apiServer' => _apiServer(bindings, request[2] as String), + 'subscribeTaskEvents' => _subscribeTaskEvents( + bindings, + taskEventCallback.nativeFunction.address, + request[2] as int, + ), _ => throw StateError('Unknown libgopeed worker request: $type'), }; - mainSendPort.send([requestID, true, response]); + mainSendPort.send(['response', requestID, true, response]); + if (type == 'stop') { + invokeResultCallback.close(); + taskEventCallback.close(); + requests.close(); + } } catch (error) { - mainSendPort.send([requestID, false, error.toString()]); + mainSendPort.send(['response', requestID, false, error.toString()]); } }); } + static int _start(LibgopeedBind bindings, String config) { + final configPtr = config.toNativeUtf8(); + try { + final result = bindings.Start(configPtr.cast()); + if (result.r1 != nullptr) { + try { + throw StateError(result.r1.cast().toDartString()); + } finally { + bindings.FreeCString(result.r1); + } + } + return result.r0; + } finally { + malloc.free(configPtr); + } + } + + static Object? _stopNative(LibgopeedBind bindings) { + bindings.Stop(); + return null; + } + + static Object? _subscribeTaskEvents(LibgopeedBind bindings, int callback, int mask) { + bindings.SubscribeTaskEvents(mask, mask == 0 ? 0 : callback); + return null; + } + static String _apiServer(LibgopeedBind bindings, String operation) { final resultPtr = switch (operation) { 'get' => bindings.GetAPIServerState(), @@ -112,26 +236,26 @@ class LibgopeedWorker { } } - static String _invoke(LibgopeedBind bindings, String method, String path, String query, String body) { + static void _invokeAsync( + LibgopeedBind bindings, + int callback, + int requestID, + String method, + String path, + String query, + String body, + ) { final methodPtr = method.toNativeUtf8(); final pathPtr = path.toNativeUtf8(); final queryPtr = query.toNativeUtf8(); final bodyPtr = body.toNativeUtf8(); - Pointer? resultPtr; try { - resultPtr = bindings.Invoke(methodPtr.cast(), pathPtr.cast(), queryPtr.cast(), bodyPtr.cast()); - if (resultPtr == nullptr) { - throw StateError('Gopeed Invoke returned a null response'); - } - return resultPtr.cast().toDartString(); + bindings.InvokeAsync(methodPtr.cast(), pathPtr.cast(), queryPtr.cast(), bodyPtr.cast(), requestID, callback); } finally { malloc.free(methodPtr); malloc.free(pathPtr); malloc.free(queryPtr); malloc.free(bodyPtr); - if (resultPtr != null && resultPtr != nullptr) { - bindings.FreeCString(resultPtr); - } } } diff --git a/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_native.dart b/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_native.dart index 36f3f3e5..faad20b7 100644 --- a/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_native.dart +++ b/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_native.dart @@ -2,9 +2,9 @@ import 'dart:convert'; import 'package:dio/dio.dart'; -import '../../../libgopeed_boot.dart'; -import '../../../ffi/libgopeed_worker.dart'; import '../../../../util/util.dart'; +import '../../../ffi/libgopeed_worker.dart'; +import '../../../libgopeed_boot.dart'; import '../gopeed_transport.dart'; GopeedTransport createTransport(GopeedTransportConfig config) => NativeGopeedTransport(); @@ -32,14 +32,16 @@ class NativeGopeedTransport implements GopeedTransport { }) async { final request = _RequestParts.from(path, queryParameters); final requestBody = data == null ? '' : jsonEncode(data); - final payload = _ffiWorker != null - ? await _ffiWorker.invoke(method.toUpperCase(), request.path, request.query, requestBody) - : await LibgopeedBoot.instance.invoke( - method.toUpperCase(), - request.path, - query: request.query, - body: requestBody, - ); + final worker = _ffiWorker; + if (worker != null) { + return worker.invoke(method.toUpperCase(), request.path, request.query, requestBody); + } + final payload = await LibgopeedBoot.instance.invoke( + method.toUpperCase(), + request.path, + query: request.query, + body: requestBody, + ); return jsonDecode(payload); } diff --git a/ui/flutter/lib/core/utils/compact_count_formatter.dart b/ui/flutter/lib/core/utils/compact_count_formatter.dart new file mode 100644 index 00000000..f0aadfe1 --- /dev/null +++ b/ui/flutter/lib/core/utils/compact_count_formatter.dart @@ -0,0 +1,23 @@ +abstract final class CompactCountFormatter { + // Counts start compacting at ten thousand by product convention, then use + // progressively larger decimal units so the rendered label stays bounded. + static const _units = [ + (divisor: 1000000000000000000, suffix: 'qi'), + (divisor: 1000000000000000, suffix: 'q'), + (divisor: 1000000000000, suffix: 't'), + (divisor: 1000000000, suffix: 'b'), + (divisor: 1000000, suffix: 'm'), + (divisor: 10000, suffix: 'w'), + ]; + + static String format(int count) { + if (count <= 0) return '0'; + if (count < _units.last.divisor) return count.toString(); + + final unit = _units.firstWhere((unit) => count >= unit.divisor); + final whole = count ~/ unit.divisor; + final tenth = (count % unit.divisor) ~/ (unit.divisor ~/ 10); + final value = whole < 10 && tenth > 0 ? '$whole.$tenth' : '$whole'; + return '$value${unit.suffix}'; + } +} diff --git a/ui/flutter/lib/core/utils/file_explorer_native.dart b/ui/flutter/lib/core/utils/file_explorer_native.dart index f3d7b9fa..715d7fb5 100644 --- a/ui/flutter/lib/core/utils/file_explorer_native.dart +++ b/ui/flutter/lib/core/utils/file_explorer_native.dart @@ -19,7 +19,7 @@ Future open(String filePath) async { Future reveal(String filePath) async { try { if (!(Platform.isWindows || Platform.isMacOS || Platform.isLinux)) { - return open(filePath); + return false; } if (await FileSystemEntity.isFile(filePath)) { diff --git a/ui/flutter/lib/core/window/app_window_bootstrap.dart b/ui/flutter/lib/core/window/app_window_bootstrap.dart index 275e58c8..e2b29316 100644 --- a/ui/flutter/lib/core/window/app_window_bootstrap.dart +++ b/ui/flutter/lib/core/window/app_window_bootstrap.dart @@ -16,6 +16,7 @@ class AppWindowBootstrap { await windowManager.ensureInitialized(); final windowOptions = WindowOptions( + title: 'Gopeed', size: const Size(1024, 768), center: true, skipTaskbar: false, @@ -74,8 +75,8 @@ class AppWindowBootstrap { static String subWindowTitle(AppWindowType type, AppLocalizations l10n) { return switch (type) { - AppWindowType.createTask => '${l10n.create} - Gopeed', - _ => l10n.appTitle, + AppWindowType.createTask => 'Gopeed - ${l10n.create}', + _ => 'Gopeed', }; } diff --git a/ui/flutter/lib/features/auth/presentation/widgets/login_form.dart b/ui/flutter/lib/features/auth/presentation/widgets/login_form.dart index 55b40220..f090e820 100644 --- a/ui/flutter/lib/features/auth/presentation/widgets/login_form.dart +++ b/ui/flutter/lib/features/auth/presentation/widgets/login_form.dart @@ -6,6 +6,7 @@ import '../../../../l10n/l10n.dart'; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_loading_button.dart'; +import '../../../../shared/widgets/app_text_field.dart'; class LoginForm extends StatelessWidget { const LoginForm({ @@ -159,7 +160,7 @@ class _LoginField extends StatelessWidget { style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w600), ), const SizedBox(height: AppDesignTokens.space8), - shad.TextField( + AppTextField( key: fieldKey, controller: controller, focusNode: focusNode, diff --git a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart index 9c2f57ab..d4effbec 100644 --- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart +++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart @@ -11,10 +11,12 @@ import 'package:url_launcher/url_launcher.dart'; import '../../../../api/model/extension.dart' as api_extension; import '../../../../api/model/store_extension.dart'; import '../../../../core/utils/breakpoints.dart'; +import '../../../../core/utils/compact_count_formatter.dart'; import '../../../../core/window/app_window_chrome.dart'; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_tooltip.dart'; import '../../../../shared/widgets/app_toast.dart'; import '../../../../l10n/l10n.dart'; @@ -23,8 +25,10 @@ import '../../application/extensions_controller.dart'; import '../../application/pending_extension_install.dart'; import '../widgets/extension_detail_view.dart'; import '../widgets/extension_icon.dart'; +import '../widgets/extension_setting_field.dart'; +import '../widgets/extension_update_dialog.dart'; -const _extensionCardMinWidth = 280.0; +const _extensionCardMinWidth = 290.0; const _extensionGridSpacing = 10.0; int _extensionGridColumnCount(double width) { @@ -44,6 +48,9 @@ class ExtensionsPage extends ConsumerStatefulWidget { class _ExtensionsPageState extends ConsumerState { final _searchController = TextEditingController(); final _installController = TextEditingController(); + final _installAnchorKey = GlobalKey(); + final _installPopoverKey = GlobalKey<_InstallPopoverState>(); + bool _installDevMode = false; final _listScrollController = ScrollController(); final Map _settingControllers = {}; ExtensionListItem? _detailItem; @@ -75,15 +82,28 @@ class _ExtensionsPageState extends ConsumerState { Widget build(BuildContext context) { final palette = AppPalette.of(context); final isDesktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; - ref.listen(pendingExtensionInstallProvider, (previous, next) { - if (next == null) return; - ref.read(pendingExtensionInstallProvider.notifier).clear(); - unawaited( - _runAction( - () => ref.read(extensionsControllerProvider.notifier).installFromUrl(next.url, devInstall: next.devMode), - ), - ); - }); + final pendingInstall = ref.watch(pendingExtensionInstallProvider); + if (pendingInstall != null) { + // The request can arrive before this page is mounted. Consume it only + // after the toolbar is laid out, so cold and warm links share the same UI. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !identical(ref.read(pendingExtensionInstallProvider), pendingInstall)) return; + if (_installPopoverKey.currentState?._installing == true) return; + final anchorContext = _installAnchorKey.currentContext; + if (anchorContext == null) return; + _installController.text = pendingInstall.url; + _installDevMode = pendingInstall.devMode; + _showInstallPopover(anchorContext); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !identical(ref.read(pendingExtensionInstallProvider), pendingInstall)) return; + final popover = _installPopoverKey.currentState; + if (popover == null || popover._installing) return; + ref.read(pendingExtensionInstallProvider.notifier).clear(); + unawaited(popover._install()); + }); + WidgetsBinding.instance.ensureVisualUpdate(); + }); + } final stateAsync = ref.watch(extensionsControllerProvider); final body = Stack( children: [ @@ -151,6 +171,7 @@ class _ExtensionsPageState extends ConsumerState { onSearch: (query) => _runAction(() => ref.read(extensionsControllerProvider.notifier).searchStore(query)), onSort: (sort) => _runAction(() => ref.read(extensionsControllerProvider.notifier).changeSort(sort)), onFilter: ref.read(extensionsControllerProvider.notifier).changeFilter, + installAnchorKey: _installAnchorKey, onOpenInstall: _openInstallPopover, onDevelopExtension: _openExtensionDevelopmentDocs, onInstallFolder: _installFromFolder, @@ -166,7 +187,9 @@ class _ExtensionsPageState extends ConsumerState { Future _installFromUrl() async { final url = _installController.text.trim(); if (url.isEmpty) return; - await _runAction(() => ref.read(extensionsControllerProvider.notifier).installFromUrl(url)); + await _runAction( + () => ref.read(extensionsControllerProvider.notifier).installFromUrl(url, devInstall: _installDevMode), + ); } Future _loadNextPageIfNeeded() async { @@ -189,7 +212,14 @@ class _ExtensionsPageState extends ConsumerState { } void _openInstallPopover(BuildContext anchorContext) { - ref.read(extensionsControllerProvider.notifier).tryOpenDevMode(); + if (AppWindowChrome.isDesktopWindow) { + ref.read(extensionsControllerProvider.notifier).tryOpenDevMode(); + } + _installDevMode = false; + _showInstallPopover(anchorContext); + } + + void _showInstallPopover(BuildContext anchorContext) { if (_installPopover != null) return; final overlay = const shad.PopoverOverlayHandler().show( context: anchorContext, @@ -198,7 +228,17 @@ class _ExtensionsPageState extends ConsumerState { offset: const Offset(0, 8), modal: false, consumeOutsideTaps: false, - builder: (context) => _InstallPopover(controller: _installController, onInstallUrl: _installFromUrl), + // Flutter's native selection toolbar uses the EditableText tap group. + // Pasting from it must not dismiss the form and restore search focus. + regionGroupId: EditableText, + builder: (context) => _InstallPopover( + key: _installPopoverKey, + controller: _installController, + onInstallUrl: _installFromUrl, + onFinished: () { + if (mounted && ref.read(pendingExtensionInstallProvider) != null) setState(() {}); + }, + ), ); _installPopover = overlay; unawaited( @@ -209,7 +249,7 @@ class _ExtensionsPageState extends ConsumerState { } Future _installFromFolder() async { - final folder = await FilePicker.platform.getDirectoryPath(); + final folder = await FilePicker.getDirectoryPath(); if (folder == null || folder.isEmpty) return; await _runAction(() => ref.read(extensionsControllerProvider.notifier).installFromUrl(folder, devInstall: true)); } @@ -281,6 +321,7 @@ class _Content extends StatelessWidget { required this.onSearch, required this.onSort, required this.onFilter, + required this.installAnchorKey, required this.onOpenInstall, required this.onDevelopExtension, required this.onInstallFolder, @@ -298,6 +339,7 @@ class _Content extends StatelessWidget { final ValueChanged onSearch; final ValueChanged onSort; final ValueChanged onFilter; + final GlobalKey installAnchorKey; final ValueChanged onOpenInstall; final VoidCallback onDevelopExtension; final VoidCallback onInstallFolder; @@ -319,6 +361,7 @@ class _Content extends StatelessWidget { onSearch: onSearch, onSort: onSort, onRefresh: onRefresh, + installAnchorKey: installAnchorKey, onOpenInstall: onOpenInstall, onDevelopExtension: onDevelopExtension, onInstallFolder: onInstallFolder, @@ -611,6 +654,7 @@ class _Toolbar extends StatelessWidget { required this.onSearch, required this.onSort, required this.onRefresh, + required this.installAnchorKey, required this.onOpenInstall, required this.onDevelopExtension, required this.onInstallFolder, @@ -621,6 +665,7 @@ class _Toolbar extends StatelessWidget { final ValueChanged onSearch; final ValueChanged onSort; final VoidCallback onRefresh; + final GlobalKey installAnchorKey; final ValueChanged onOpenInstall; final VoidCallback onDevelopExtension; final VoidCallback onInstallFolder; @@ -628,10 +673,10 @@ class _Toolbar extends StatelessWidget { @override Widget build(BuildContext context) { final palette = AppPalette.of(context); - final manualInstallBusy = state.busyExtensionIds.contains(ExtensionsController.manualInstallBusyKey); - final search = shad.TextField( + final search = AppTextField( key: const ValueKey('extension-search-input'), controller: searchController, + padding: MediaQuery.sizeOf(context).width >= Breakpoints.mobile ? AppDesignTokens.compactTextFieldPadding : null, placeholder: Text(context.l10n.searchExtensions, style: TextStyle(color: palette.searchHint)), features: [shad.InputFeature.leading(Icon(Icons.search_rounded, size: 16, color: palette.textMuted))], onSubmitted: onSearch, @@ -669,22 +714,21 @@ class _Toolbar extends StatelessWidget { onPressed: onDevelopExtension, ), const SizedBox(width: 8), - if (state.devMode) ...[ + if (AppWindowChrome.isDesktopWindow && state.devMode) ...[ _OutlineToolbarIconButton( key: const ValueKey('load-local-extension-button'), tooltip: context.l10n.extensionLoadLocal, icon: Icons.folder_open_outlined, - loading: manualInstallBusy, onPressed: onInstallFolder, ), const SizedBox(width: 8), ], Builder( + key: installAnchorKey, builder: (buttonContext) => _OutlineToolbarIconButton( key: const ValueKey('install-extension-button'), tooltip: context.l10n.extensionInstallFromUrl, icon: Icons.add_link, - loading: manualInstallBusy, onPressed: () => onOpenInstall(buttonContext), ), ), @@ -760,18 +804,11 @@ class _FilterBar extends StatelessWidget { } class _OutlineToolbarIconButton extends StatelessWidget { - const _OutlineToolbarIconButton({ - super.key, - required this.tooltip, - required this.icon, - required this.onPressed, - this.loading = false, - }); + const _OutlineToolbarIconButton({super.key, required this.tooltip, required this.icon, required this.onPressed}); final String tooltip; final IconData icon; final VoidCallback? onPressed; - final bool loading; @override Widget build(BuildContext context) { @@ -779,23 +816,18 @@ class _OutlineToolbarIconButton extends StatelessWidget { message: tooltip, child: SizedBox.square( dimension: 32, - child: shad.IconButton.outline( - size: shad.ButtonSize.xSmall, - onPressed: loading ? null : onPressed, - icon: loading - ? const SizedBox.square(dimension: 13, child: shad.CircularProgressIndicator()) - : Icon(icon, size: 17), - ), + child: shad.IconButton.outline(size: shad.ButtonSize.xSmall, onPressed: onPressed, icon: Icon(icon, size: 17)), ), ); } } class _InstallPopover extends StatefulWidget { - const _InstallPopover({required this.controller, required this.onInstallUrl}); + const _InstallPopover({super.key, required this.controller, required this.onInstallUrl, required this.onFinished}); final TextEditingController controller; final Future Function() onInstallUrl; + final VoidCallback onFinished; @override State<_InstallPopover> createState() => _InstallPopoverState(); @@ -826,7 +858,7 @@ class _InstallPopoverState extends State<_InstallPopover> { style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700), ), const SizedBox(height: 10), - shad.TextField( + AppTextField( key: const ValueKey('extension-install-url-input'), controller: widget.controller, autofocus: true, @@ -857,8 +889,8 @@ class _InstallPopoverState extends State<_InstallPopover> { setState(() => _installing = true); await widget.onInstallUrl(); if (!mounted) return; - setState(() => _installing = false); await shad.closeOverlay(context); + widget.onFinished(); } } @@ -992,71 +1024,97 @@ class _ExtensionCard extends ConsumerWidget { const Spacer(), Row( children: [ - if (item.store != null) ...[ - Icon(Icons.star_rounded, size: 15, color: palette.textMuted), - const SizedBox(width: 3), - Text('${item.stars}', style: TextStyle(color: palette.textSecondary, fontSize: 12)), - const SizedBox(width: 10), - Icon(Icons.download_outlined, size: 15, color: palette.textMuted), - const SizedBox(width: 3), - Text('${item.installCount}', style: TextStyle(color: palette.textSecondary, fontSize: 12)), - ], - const Spacer(), - if (canUpdate && installed != null) - shad.GhostButton( - density: shad.ButtonDensity.icon, - onPressed: busy - ? null - : () => - onAction(() => ref.read(extensionsControllerProvider.notifier).upgradeExtension(installed)), - child: const Icon(Icons.refresh), - ), - if (installed == null && item.store != null) - shad.GhostButton( - key: ValueKey('install-store-extension-${item.store!.id}'), - density: shad.ButtonDensity.icon, - onPressed: busy - ? null - : () => onAction( - () => ref.read(extensionsControllerProvider.notifier).installFromStore(item.store!), - ), - child: busy - ? const SizedBox.square(dimension: 14, child: shad.CircularProgressIndicator()) - : const Icon(Icons.download), - ), - if ((item.homepage ?? '').isNotEmpty) - shad.GhostButton( - density: shad.ButtonDensity.icon, - onPressed: () => unawaited(launchUrl(Uri.parse(item.homepage!))), - child: const Icon(Icons.home_outlined), - ), - if ((item.repoUrl ?? '').isNotEmpty) - shad.GhostButton( - density: shad.ButtonDensity.icon, - onPressed: () => unawaited(launchUrl(Uri.parse(item.repoUrl!))), - child: const Icon(Icons.code), - ), - if (installed?.settings?.isNotEmpty == true) - shad.GhostButton( - density: shad.ButtonDensity.icon, - onPressed: busy ? null : () => onOpenSettings(installed!), - child: const Icon(Icons.settings_outlined), - ), - if (installed != null) - shad.GhostButton( - key: ValueKey('remove-extension-${installed.identity}'), - density: shad.ButtonDensity.icon, - onPressed: busy - ? null - : () async { - final confirmed = await _showRemoveExtensionDialog(context, installed.title); - if (!confirmed || !context.mounted) return; - await onAction( - () => ref.read(extensionsControllerProvider.notifier).removeExtension(installed), - ); - }, - child: const Icon(Icons.delete_outline), - ), + if (item.store != null) + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + key: ValueKey('extension-card-stats-${item.id}'), + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.star_rounded, size: 15, color: palette.textMuted), + const SizedBox(width: 3), + Text( + CompactCountFormatter.format(item.stars), + style: TextStyle(color: palette.textSecondary, fontSize: 12), + ), + const SizedBox(width: 10), + Icon(Icons.download_outlined, size: 15, color: palette.textMuted), + const SizedBox(width: 3), + Text( + CompactCountFormatter.format(item.installCount), + style: TextStyle(color: palette.textSecondary, fontSize: 12), + ), + ], + ), + ), + ), + ) + else + const Spacer(), + const SizedBox(width: 8), + Row( + key: ValueKey('extension-card-actions-${item.id}'), + mainAxisSize: MainAxisSize.min, + children: [ + if (canUpdate && installed != null) + shad.GhostButton( + density: shad.ButtonDensity.icon, + key: ValueKey('update-extension-${installed.identity}'), + onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed), + child: const Icon(Icons.refresh), + ), + if (installed == null && item.store != null) + shad.GhostButton( + key: ValueKey('install-store-extension-${item.store!.id}'), + density: shad.ButtonDensity.icon, + onPressed: busy + ? null + : () => onAction( + () => ref.read(extensionsControllerProvider.notifier).installFromStore(item.store!), + ), + child: busy + ? const SizedBox.square(dimension: 14, child: shad.CircularProgressIndicator()) + : const Icon(Icons.download), + ), + if ((item.homepage ?? '').isNotEmpty) + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: () => unawaited(launchUrl(Uri.parse(item.homepage!))), + child: const Icon(Icons.home_outlined), + ), + if ((item.repoUrl ?? '').isNotEmpty) + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: () => unawaited(launchUrl(Uri.parse(item.repoUrl!))), + child: const Icon(Icons.code), + ), + if (installed?.settings?.isNotEmpty == true) + shad.GhostButton( + density: shad.ButtonDensity.icon, + onPressed: busy ? null : () => onOpenSettings(installed!), + child: const Icon(Icons.settings_outlined), + ), + if (installed != null) + shad.GhostButton( + key: ValueKey('remove-extension-${installed.identity}'), + density: shad.ButtonDensity.icon, + onPressed: busy + ? null + : () async { + final confirmed = await _showRemoveExtensionDialog(context, installed.title); + if (!confirmed || !context.mounted) return; + await onAction( + () => ref.read(extensionsControllerProvider.notifier).removeExtension(installed), + ); + }, + child: const Icon(Icons.delete_outline), + ), + ], + ), ], ), ], @@ -1172,7 +1230,7 @@ class _ExtensionSettingsPanel extends StatelessWidget { separatorBuilder: (_, _) => const SizedBox(height: 14), itemBuilder: (context, index) { final setting = settings[index]; - return _ExtensionSettingField(setting: setting, controller: controllers[setting.name]!); + return ExtensionSettingField(setting: setting, controller: controllers[setting.name]!); }, ), ), @@ -1200,39 +1258,6 @@ class _ExtensionSettingsPanel extends StatelessWidget { } } -class _ExtensionSettingField extends StatelessWidget { - const _ExtensionSettingField({required this.setting, required this.controller}); - - final api_extension.Setting setting; - final TextEditingController controller; - - @override - Widget build(BuildContext context) { - final palette = AppPalette.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - setting.title, - style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700), - ), - if (setting.description.isNotEmpty) ...[ - const SizedBox(height: 4), - Text(setting.description, style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.35)), - ], - const SizedBox(height: 8), - if (setting.type == api_extension.SettingType.boolean) - shad.TextField(controller: controller, placeholder: Text(context.l10n.booleanValueHint)) - else - shad.TextField( - controller: controller, - keyboardType: setting.type == api_extension.SettingType.number ? TextInputType.number : TextInputType.text, - ), - ], - ); - } -} - class _ErrorState extends StatelessWidget { const _ErrorState({required this.error, required this.onRetry}); diff --git a/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart b/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart index 886be8ef..87837560 100644 --- a/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart +++ b/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart @@ -10,6 +10,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import 'package:url_launcher/url_launcher.dart'; import '../../../../api/model/store_extension.dart'; +import '../../../../core/utils/compact_count_formatter.dart'; import '../../../../l10n/l10n.dart'; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; @@ -19,6 +20,7 @@ import '../../../../shared/widgets/detail/app_detail_surface.dart'; import '../../../../util/util.dart'; import '../../application/extensions_controller.dart'; import 'extension_icon.dart'; +import 'extension_update_dialog.dart'; class ExtensionDetailDrawer extends StatelessWidget { const ExtensionDetailDrawer({super.key, required this.item, required this.onClose}); @@ -131,14 +133,10 @@ class ExtensionDetailView extends ConsumerWidget { if (installed != null && canUpdate) AppLoadingButton( key: const ValueKey('extension-details-update'), - loading: busy, + loading: false, variant: AppLoadingButtonVariant.primary, icon: const Icon(Icons.refresh, size: 16), - onPressed: () => _runAction( - context, - () => ref.read(extensionsControllerProvider.notifier).upgradeExtension(installed), - successMessage: context.l10n.extensionUpdateSuccess, - ), + onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed), child: Text(context.l10n.newVersionUpdate), ), if (installed != null && !canUpdate) @@ -338,9 +336,9 @@ class _ExtensionStats extends StatelessWidget { key: const ValueKey('extension-details-stats'), mainAxisSize: MainAxisSize.min, children: [ - _MetadataItem(icon: Icons.star_rounded, value: store.stars.toString()), + _MetadataItem(icon: Icons.star_rounded, value: CompactCountFormatter.format(store.stars)), const SizedBox(width: 12), - _MetadataItem(icon: Icons.download_outlined, value: store.installCount.toString()), + _MetadataItem(icon: Icons.download_outlined, value: CompactCountFormatter.format(store.installCount)), ], ); } diff --git a/ui/flutter/lib/features/extensions/presentation/widgets/extension_setting_field.dart b/ui/flutter/lib/features/extensions/presentation/widgets/extension_setting_field.dart new file mode 100644 index 00000000..eb0d8846 --- /dev/null +++ b/ui/flutter/lib/features/extensions/presentation/widgets/extension_setting_field.dart @@ -0,0 +1,87 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:flutter/services.dart'; + +import '../../../../api/model/extension.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_text_field.dart'; + +class ExtensionSettingField extends StatelessWidget { + const ExtensionSettingField({super.key, required this.setting, required this.controller}); + + final Setting setting; + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + setting.title, + style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700), + ), + if (setting.description.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(setting.description, style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.35)), + ], + const SizedBox(height: 8), + _buildInput(), + ], + ); + } + + Widget _buildInput() { + if (setting.type == SettingType.boolean) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) => + Switch(value: value.text == 'true', onChanged: (enabled) => controller.text = enabled.toString()), + ); + } + + final options = setting.options; + if (options != null && options.isNotEmpty) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) => LayoutBuilder( + builder: (context, constraints) => Select( + value: options.any((option) => option.value.toString() == value.text) ? value.text : null, + placeholder: Text(setting.title), + constraints: BoxConstraints.tightFor(width: constraints.maxWidth), + popupConstraints: const BoxConstraints(maxHeight: 240), + itemBuilder: (context, selectedValue) => Text( + options.firstWhere((option) => option.value.toString() == selectedValue).label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + popup: (context) => SelectPopup( + items: SelectItemList( + children: [ + for (final option in options) + SelectItemButton(value: option.value.toString(), child: Text(option.label)), + ], + ), + ), + onChanged: (selectedValue) { + if (selectedValue != null) controller.text = selectedValue; + }, + ), + ), + ); + } + + final isNumber = setting.type == SettingType.number; + return AppTextField( + controller: controller, + keyboardType: isNumber ? const TextInputType.numberWithOptions(decimal: true, signed: true) : TextInputType.text, + inputFormatters: isNumber + ? [ + TextInputFormatter.withFunction( + (oldValue, newValue) => RegExp(r'^-?\d*\.?\d*$').hasMatch(newValue.text) ? newValue : oldValue, + ), + ] + : null, + ); + } +} diff --git a/ui/flutter/lib/features/extensions/presentation/widgets/extension_update_dialog.dart b/ui/flutter/lib/features/extensions/presentation/widgets/extension_update_dialog.dart new file mode 100644 index 00000000..107a46bc --- /dev/null +++ b/ui/flutter/lib/features/extensions/presentation/widgets/extension_update_dialog.dart @@ -0,0 +1,90 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../api/model/extension.dart' as api; +import '../../../../l10n/l10n.dart'; +import '../../../../shared/widgets/app_loading_button.dart'; +import '../../../../shared/widgets/app_toast.dart'; +import '../../application/extensions_controller.dart'; + +Future showExtensionUpdateDialog(BuildContext context, api.Extension extension) async { + final overlay = const DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: false, + builder: (_) => _ExtensionUpdateDialog(extension: extension), + ); + final updated = await overlay.future; + if (updated == true && context.mounted) { + showAppToast(context, context.l10n.extensionUpdateSuccess); + } +} + +class _ExtensionUpdateDialog extends ConsumerStatefulWidget { + const _ExtensionUpdateDialog({required this.extension}); + + final api.Extension extension; + + @override + ConsumerState<_ExtensionUpdateDialog> createState() => _ExtensionUpdateDialogState(); +} + +class _ExtensionUpdateDialogState extends ConsumerState<_ExtensionUpdateDialog> { + bool _updating = false; + bool _failed = false; + + Future _update() async { + if (_updating) return; + setState(() { + _updating = true; + _failed = false; + }); + try { + await ref.read(extensionsControllerProvider.notifier).upgradeExtension(widget.extension); + } catch (_) { + if (!mounted) return; + setState(() { + _updating = false; + _failed = true; + }); + return; + } + if (mounted) await closeOverlay(context, true); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: !_updating, + child: AlertDialog( + key: const ValueKey('extension-update-dialog'), + title: Text(context.l10n.extensionCanUpdate), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.extensionUpdateConfirm(widget.extension.title)), + if (_failed) ...[ + const SizedBox(height: 12), + Text(context.l10n.updateFailed, key: const ValueKey('extension-update-error')), + ], + ], + ), + actions: [ + SecondaryButton( + key: const ValueKey('cancel-update-extension-button'), + onPressed: _updating ? null : () => closeOverlay(context, false), + child: Text(context.l10n.cancel), + ), + AppLoadingButton( + key: const ValueKey('confirm-update-extension-button'), + variant: AppLoadingButtonVariant.primary, + loading: _updating, + onPressed: _update, + child: Text(context.l10n.newVersionUpdate), + ), + ], + ), + ); + } +} diff --git a/ui/flutter/lib/features/home/presentation/pages/home_page.dart b/ui/flutter/lib/features/home/presentation/pages/home_page.dart index 5d356b3b..a4b3b2aa 100644 --- a/ui/flutter/lib/features/home/presentation/pages/home_page.dart +++ b/ui/flutter/lib/features/home/presentation/pages/home_page.dart @@ -19,6 +19,7 @@ import '../../../../shared/widgets/responsive_navigation_layout.dart'; import '../../../../l10n/l10n.dart'; import '../../../../util/util.dart'; import '../../../tasks/application/tasks_controller.dart'; +import '../../../tasks/application/task_list_navigation.dart'; import '../../../tasks/application/pending_update_task.dart'; import '../../../tasks/application/task_batch_selection_controller.dart'; import '../../../tasks/domain/task_record.dart'; @@ -29,6 +30,7 @@ import '../../../tasks/presentation/widgets/task_drawer.dart'; import '../../../tasks/presentation/widgets/task_context_menu.dart'; import '../../../tasks/presentation/widgets/task_delete_dialog.dart'; import '../../../tasks/presentation/widgets/task_empty_state.dart'; +import '../../../tasks/presentation/widgets/task_create_button.dart'; import '../../../tasks/presentation/widgets/task_file_browser_dialog.dart'; import '../../../tasks/presentation/widgets/task_update_url_dialog.dart'; import '../widgets/primary_rail.dart'; @@ -86,6 +88,11 @@ class _HomePageState extends ConsumerState { Widget build(BuildContext context) { final palette = AppPalette.of(context); final tasksAsync = ref.watch(tasksControllerProvider); + ref.listen(taskListNavigationProvider, (_, _) { + _searchController.clear(); + _setActiveFilter(_TaskFilter.downloading); + setState(() => _batchMode = false); + }); final tasksState = tasksAsync.value; final tasks = tasksState?.tasks ?? const []; final filteredTasks = _filteredTasks(tasks); @@ -166,7 +173,10 @@ class _HomePageState extends ConsumerState { loading: () => const Center(child: shad.CircularProgressIndicator()), error: (error, _) => const TaskEmptyState(), data: (_) => filteredTasks.isEmpty - ? TaskEmptyState(message: emptyStateMessage) + ? TaskEmptyState( + message: emptyStateMessage, + onCreateTask: _searchController.text.trim().isEmpty ? _handleAddTask : null, + ) : ListView.separated( padding: const EdgeInsets.only(bottom: 16), itemCount: filteredTasks.length, @@ -254,6 +264,7 @@ class _HomePageState extends ConsumerState { data: (_) => _MobileTasksView( tasks: filteredTasks, emptyStateMessage: emptyStateMessage, + onCreateTask: _searchController.text.trim().isEmpty ? _handleAddTask : null, batchMode: _batchMode, batchSelection: _batchSelection, onToggleSelectAll: _toggleSelectAll, @@ -679,6 +690,7 @@ class _MobileTasksView extends StatelessWidget { required this.contextActionsForTask, required this.pendingUpdateTaskId, this.emptyStateMessage, + this.onCreateTask, this.loading = false, }); @@ -699,6 +711,7 @@ class _MobileTasksView extends StatelessWidget { final TaskContextActions Function(TaskRecord task, bool selected, bool allSelected) contextActionsForTask; final String? pendingUpdateTaskId; final String? emptyStateMessage; + final VoidCallback? onCreateTask; final bool loading; @override @@ -739,7 +752,7 @@ class _MobileTasksView extends StatelessWidget { child: loading ? const Center(child: shad.CircularProgressIndicator()) : tasks.isEmpty - ? TaskEmptyState(message: emptyStateMessage) + ? TaskEmptyState(message: emptyStateMessage, onCreateTask: onCreateTask) : ListView.separated( padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), itemCount: tasks.length, @@ -808,9 +821,19 @@ class _MobileTasksHeader extends StatelessWidget { style: TextStyle(color: palette.textPrimary, fontSize: 22, fontWeight: FontWeight.w800), ), ), - _MobileHeaderIconButton(icon: Icons.add_rounded, onPressed: onAddTask), + const SizedBox(width: AppDesignTokens.space8), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.5), + child: TaskCreateButton( + key: const ValueKey('tasks-mobile-create-button'), + onPressed: onAddTask, + minHeight: 34, + compact: true, + ), + ), const SizedBox(width: 8), _MobileHeaderIconButton( + key: const ValueKey('tasks-mobile-batch-button'), icon: Icons.checklist_rtl_outlined, active: batchMode, badge: batchMode && selectedCount > 0 ? selectedCount.toString() : null, @@ -897,7 +920,13 @@ class _MobileBatchToolbar extends StatelessWidget { } class _MobileHeaderIconButton extends StatelessWidget { - const _MobileHeaderIconButton({required this.icon, required this.onPressed, this.active = false, this.badge}); + const _MobileHeaderIconButton({ + super.key, + required this.icon, + required this.onPressed, + this.active = false, + this.badge, + }); final IconData icon; final VoidCallback onPressed; diff --git a/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart b/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart index 25063d30..5805d4bf 100644 --- a/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart +++ b/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart @@ -4,7 +4,8 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; -import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../tasks/presentation/widgets/task_create_button.dart'; +import '../../../../shared/widgets/app_text_field.dart'; import '../../../../l10n/l10n.dart'; class TasksTopBar extends StatelessWidget { @@ -61,7 +62,7 @@ class TasksTopBar extends StatelessWidget { ConstrainedBox( key: const ValueKey('tasks-create-button-container'), constraints: const BoxConstraints(minWidth: 120, maxWidth: 190), - child: _AddTaskButton(onPressed: onAddTask), + child: TaskCreateButton(onPressed: onAddTask), ), const SizedBox(width: 16), Row( @@ -110,7 +111,7 @@ class _SearchField extends StatelessWidget { ), child: SizedBox( height: 40, - child: shad.TextField( + child: AppTextField( controller: controller, style: TextStyle(color: palette.textPrimary, fontSize: 14, height: 1), placeholder: Text( @@ -130,27 +131,6 @@ class _SearchField extends StatelessWidget { } } -class _AddTaskButton extends StatelessWidget { - const _AddTaskButton({required this.onPressed}); - - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return AppPrimaryButton( - onPressed: onPressed, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.add), - const SizedBox(width: AppDesignTokens.space8), - Flexible(child: Text(context.l10n.create, maxLines: 1, overflow: TextOverflow.ellipsis)), - ], - ), - ); - } -} - class _ActionIconButton extends StatelessWidget { const _ActionIconButton({required this.icon, required this.onPressed}); diff --git a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart index 8ee76f8f..c78a18a1 100644 --- a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart +++ b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' show Divider, Icons, Scrollbar, ScrollbarOrientation; import 'package:flutter/widgets.dart'; @@ -14,6 +13,7 @@ import '../../../../app/application/app_appearance_controller.dart'; import '../../../../app/application/app_platform_controller.dart'; import '../../../../app/application/app_runtime_controller.dart'; import '../../../../app/application/location_keep_alive.dart'; +import '../../../../app/application/continued_processing.dart'; import '../../../../core/common/start_config.dart'; import '../../../../core/network/gopeed/gopeed_transport.dart'; import '../../../../core/utils/breakpoints.dart'; @@ -24,6 +24,7 @@ import '../../../../shared/widgets/app_choice_segmented_control.dart'; import '../../../../shared/widgets/app_loading_button.dart'; import '../../../../shared/widgets/app_number_input.dart'; import '../../../../shared/widgets/app_path_picker_field.dart'; +import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_tooltip.dart'; import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/responsive_menu_layout.dart'; @@ -64,6 +65,8 @@ class _SettingsPageState extends ConsumerState { final _maxRunningController = TextEditingController(); final _httpUserAgentController = TextEditingController(); final _httpConnectionsController = TextEditingController(); + final _hlsConnectionsController = TextEditingController(); + final _hlsMaxRetriesController = TextEditingController(); final _btListenPortController = TextEditingController(); final _btSeedRatioController = TextEditingController(); final _btSeedTimeController = TextEditingController(); @@ -95,6 +98,8 @@ class _SettingsPageState extends ConsumerState { _maxRunningController, _httpUserAgentController, _httpConnectionsController, + _hlsConnectionsController, + _hlsMaxRetriesController, _btListenPortController, _btSeedRatioController, _btSeedTimeController, @@ -377,6 +382,19 @@ class _SettingsPageState extends ConsumerState { onChanged: (value) => unawaited(_setBackgroundLocationKeepAlive(value)), ), ), + if (Util.isIOS()) + SettingsItem( + title: context.l10n.backgroundContinuedProcessing, + subtitle: + context.l10n.backgroundContinuedProcessingDescription, + child: shad.Switch( + value: + config.extra.backgroundContinuedProcessing, + onChanged: (value) => unawaited( + _setBackgroundContinuedProcessing(value), + ), + ), + ), ], ), ), @@ -558,6 +576,32 @@ class _SettingsPageState extends ConsumerState { ], ), ), + _SettingsBlock( + key: const ValueKey('settings-hls-block'), + title: 'HLS', + child: _SettingsGroup( + children: [ + SettingsItem( + title: context.l10n.connections, + child: _NumberSettingControl( + fieldKey: const ValueKey('hls-connections-input'), + controller: _hlsConnectionsController, + min: 1, + max: 256, + ), + ), + SettingsItem( + title: context.l10n.maxRetries, + child: _NumberSettingControl( + fieldKey: const ValueKey('hls-max-retries-input'), + controller: _hlsMaxRetriesController, + min: 0, + max: 30, + ), + ), + ], + ), + ), _SettingsBlock( title: 'BitTorrent', child: _SettingsGroup( @@ -939,6 +983,8 @@ class _SettingsPageState extends ConsumerState { _maxRunningController.text = _config!.maxRunning.clamp(1, 256).toString(); _httpUserAgentController.text = _config!.protocolConfig.http.userAgent; _httpConnectionsController.text = _config!.protocolConfig.http.connections.clamp(1, 256).toString(); + _hlsConnectionsController.text = _config!.protocolConfig.hls.segmentConnections.clamp(1, 256).toString(); + _hlsMaxRetriesController.text = _config!.protocolConfig.hls.maxRetries.clamp(0, 30).toString(); _btListenPortController.text = _config!.protocolConfig.bt.listenPort.clamp(0, 65535).toString(); _btSeedRatioController.text = _config!.protocolConfig.bt.seedRatio.toString(); _btSeedTimeController.text = (_config!.protocolConfig.bt.seedTime ~/ 60).clamp(0, 100000000).toString(); @@ -1034,6 +1080,18 @@ class _SettingsPageState extends ConsumerState { min: 1, max: 256, ); + config.protocolConfig.hls.segmentConnections = _boundedInt( + _hlsConnectionsController, + fallback: config.protocolConfig.hls.segmentConnections, + min: 1, + max: 256, + ); + config.protocolConfig.hls.maxRetries = _boundedInt( + _hlsMaxRetriesController, + fallback: config.protocolConfig.hls.maxRetries, + min: 0, + max: 30, + ); config.protocolConfig.bt.listenPort = _boundedInt( _btListenPortController, fallback: config.protocolConfig.bt.listenPort, @@ -1237,6 +1295,43 @@ class _SettingsPageState extends ConsumerState { _mutateConfig((config) => config.extra.backgroundLocationKeepAlive = enabled); await LocationKeepAliveCoordinator.instance.reconcile(enabled: enabled); } + Future + _setBackgroundContinuedProcessing( + bool enabled, + ) async { + if (enabled && + !await ContinuedProcessing.isSupported()) { + if (mounted) { + _toast( + context.l10n + .backgroundContinuedProcessingUnsupported, + ); + } + return; + } + + final applied = + await ContinuedProcessing.setEnabled( + enabled, + ); + + if (enabled && !applied) { + if (mounted) { + _toast( + context.l10n + .backgroundContinuedProcessingUnsupported, + ); + } + return; + } + + _mutateConfig( + (config) => + config.extra + .backgroundContinuedProcessing = + enabled, + ); + } List _lines(String text) { return text.split('\n').map((line) => line.trim()).where((line) => line.isNotEmpty).toList(); @@ -1375,12 +1470,7 @@ class _SettingsPageState extends ConsumerState { title: index == null ? context.l10n.addScript : context.l10n.editScript, fieldLabel: context.l10n.scriptPath, initialValue: index == null ? '' : paths[index], - pickPath: Util.isDesktop() - ? () async { - final result = await FilePicker.platform.pickFiles(); - return result?.files.firstOrNull?.path; - } - : null, + pickFile: true, ); if (value == null || !mounted) return; _mutateConfig((config) { @@ -1849,7 +1939,7 @@ class _TextSettingControl extends StatelessWidget { final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; return SizedBox( width: desktop ? AppDesignTokens.settingsFormControlWidth : double.infinity, - child: shad.TextField( + child: AppTextField( key: fieldKey, controller: controller, hintText: hintText, @@ -1919,7 +2009,7 @@ class _HostPortControl extends StatelessWidget { child: Row( children: [ Expanded( - child: shad.TextField( + child: AppTextField( key: hostKey, controller: hostController, hintText: context.l10n.server, diff --git a/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart b/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart index 7367c609..35a29293 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart @@ -10,6 +10,7 @@ import '../../../../util/package_info.dart'; import '../../../../util/updater.dart'; import '../../../../util/util.dart'; import '../../../../l10n/l10n.dart'; +import 'app_update_notes_viewport.dart'; typedef StartAppUpdate = Future Function(VersionInfo versionInfo, UpdateProgressCallback onProgress); @@ -74,11 +75,18 @@ Future showAppUpdateDialog( key: const ValueKey('app-update-heading'), textAlign: TextAlign.center, ), - const SizedBox(height: 3), + const SizedBox(height: 8), Text( '$appVersion → ${versionInfo.version}', textAlign: TextAlign.center, - style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w400), + style: DefaultTextStyle.of(dialogContext).style.copyWith( + inherit: false, + color: palette.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w400, + height: 1.4, + decoration: TextDecoration.none, + ), ), ], ), @@ -90,18 +98,9 @@ Future showAppUpdateDialog( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - dialogContext.l10n.releaseNotes, - style: TextStyle(color: palette.textPrimary, fontSize: 12, fontWeight: FontWeight.w700), - ), - const SizedBox(height: 10), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 210), - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: _ReleaseNotes( - notes: releaseNotes.isEmpty ? dialogContext.l10n.noReleaseNotes : releaseNotes, - ), + AppUpdateNotesViewport( + child: _ReleaseNotes( + notes: releaseNotes.isEmpty ? dialogContext.l10n.noReleaseNotes : releaseNotes, ), ), if (updating) ...[ @@ -157,7 +156,6 @@ Future showAppUpdateDialog( onPressed: () => unawaited(update()), loading: updating, variant: AppLoadingButtonVariant.primary, - icon: const Icon(Icons.system_update_alt_outlined, size: 17), child: Text(dialogContext.l10n.newVersionUpdate), ), ], diff --git a/ui/flutter/lib/features/settings/presentation/widgets/app_update_notes_viewport.dart b/ui/flutter/lib/features/settings/presentation/widgets/app_update_notes_viewport.dart new file mode 100644 index 00000000..51dbe968 --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/widgets/app_update_notes_viewport.dart @@ -0,0 +1,59 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../shared/theme/app_design_tokens.dart'; + +class AppUpdateNotesViewport extends StatefulWidget { + const AppUpdateNotesViewport({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _AppUpdateNotesViewportState(); +} + +class _AppUpdateNotesViewportState extends State { + final _controller = ScrollController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const fadeHeight = AppDesignTokens.space24; + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: shad.Scrollbar( + controller: _controller, + thumbVisibility: true, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + // Mask colors control opacity only; they do not tint the content. + colors: const [shad.Colors.white, shad.Colors.white, shad.Colors.transparent], + stops: [0, (1 - fadeHeight / bounds.height).clamp(0.0, 1.0), 1], + ).createShader(bounds), + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: SingleChildScrollView( + controller: _controller, + padding: const EdgeInsetsDirectional.fromSTEB( + AppDesignTokens.space4, + 0, + AppDesignTokens.space16, + // Keep the final line above the fade at the end of the scroll. + fadeHeight, + ), + child: widget.child, + ), + ), + ), + ), + ); + } +} diff --git a/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart b/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart index 4f93d0ae..ae8d78ee 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart @@ -8,6 +8,7 @@ import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_path_picker_field.dart'; import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../../shared/widgets/app_text_field.dart'; import '../../../../l10n/l10n.dart'; class DownloadCategoryDraft { @@ -169,7 +170,7 @@ Future showDownloadCategoryDialog( children: [ Text(dialogContext.l10n.categoryName, style: TextStyle(color: palette.textSecondary, fontSize: 12)), const SizedBox(height: 6), - shad.TextField(key: const ValueKey('download-category-name'), controller: nameController), + AppTextField(key: const ValueKey('download-category-name'), controller: nameController), const SizedBox(height: 14), Text(dialogContext.l10n.categoryPath, style: TextStyle(color: palette.textSecondary, fontSize: 12)), const SizedBox(height: 6), diff --git a/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart b/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart index 4dfc501c..58b325ae 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/settings_list_editor.dart @@ -9,6 +9,7 @@ import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_loading_button.dart'; import '../../../../shared/widgets/app_path_picker_field.dart'; import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../../shared/widgets/app_text_field.dart'; import '../../../../l10n/l10n.dart'; class SettingsListEntry { @@ -121,7 +122,7 @@ Future showTextSettingDialog( required String fieldLabel, String initialValue = '', bool requireHttpUrl = false, - Future Function()? pickPath, + bool pickFile = false, Future Function(String value)? onTest, }) async { final controller = TextEditingController(text: initialValue); @@ -188,16 +189,12 @@ Future showTextSettingDialog( children: [ Text(fieldLabel, style: TextStyle(color: palette.textSecondary, fontSize: 12)), const SizedBox(height: 6), - if (pickPath == null) - shad.TextField(controller: controller) + if (!pickFile) + AppTextField(controller: controller) else - AppPathPickerField( + AppPathPickerField.file( controller: controller, desktopWidth: AppDesignTokens.settingsFormControlWidth, - onPick: () async { - final path = await pickPath(); - if (path != null && path.isNotEmpty) controller.text = path; - }, ), if (validationMessage != null || testMessage != null) ...[ const SizedBox(height: 8), @@ -302,7 +299,7 @@ Future showGithubMirrorDialog(BuildContext context, {GithubM const SizedBox(height: 14), Text(dialogContext.l10n.githubMirrorUrl, style: TextStyle(color: palette.textSecondary, fontSize: 12)), const SizedBox(height: 6), - shad.TextField(controller: controller), + AppTextField(controller: controller), if (validationMessage != null) ...[ const SizedBox(height: 8), Text(validationMessage!, style: TextStyle(color: palette.error, fontSize: 12)), diff --git a/ui/flutter/lib/features/tasks/application/task_list_navigation.dart b/ui/flutter/lib/features/tasks/application/task_list_navigation.dart new file mode 100644 index 00000000..1bbbf0fb --- /dev/null +++ b/ui/flutter/lib/features/tasks/application/task_list_navigation.dart @@ -0,0 +1,11 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final taskListNavigationProvider = NotifierProvider(TaskListNavigation.new); + +// A request also resets an already-visible list's filter, search and selection. +class TaskListNavigation extends Notifier { + @override + int build() => 0; + + void showDownloading() => state++; +} diff --git a/ui/flutter/lib/features/tasks/domain/task_record.dart b/ui/flutter/lib/features/tasks/domain/task_record.dart index d5c15b12..bd066ddb 100644 --- a/ui/flutter/lib/features/tasks/domain/task_record.dart +++ b/ui/flutter/lib/features/tasks/domain/task_record.dart @@ -117,7 +117,7 @@ class TaskRecord { bool get canUpdateUrl => protocol == api_task.Protocol.http && (status == TaskStatus.paused || status == TaskStatus.failed); - bool get isIndeterminate => status == TaskStatus.downloading && total == null; + bool get isIndeterminate => status == TaskStatus.downloading && !waiting && total == null; TaskAssetType get assetType => _assetType(name, isFolder: isFolder, protocol: protocol); @@ -140,7 +140,7 @@ class TaskRecord { status: status, downloaded: _formatBytes(downloadedBytes), total: totalBytes > 0 ? _formatBytes(totalBytes) : null, - speed: task.progress.speed > 0 ? TransferRateFormatter.format(task.progress.speed).text : null, + speed: totalBytes > 0 || task.progress.speed > 0 ? TransferRateFormatter.format(task.progress.speed).text : null, uploadSpeed: task.uploading ? TransferRateFormatter.format(task.progress.uploadSpeed).text : null, remainingSeconds: _remainingSeconds(task, totalBytes, downloadedBytes), downloadDuration: task.progress.used > 0 ? Duration(microseconds: (task.progress.used + 999) ~/ 1000) : null, @@ -301,13 +301,14 @@ TaskAssetType _assetType(String name, {required bool isFolder, required api_task return switch (protocol) { api_task.Protocol.bt => TaskAssetType.torrent, api_task.Protocol.ed2k => TaskAssetType.ed2k, - api_task.Protocol.http || null => TaskAssetType.file, + api_task.Protocol.hls || api_task.Protocol.http || null => TaskAssetType.file, }; } String _fileExtension(String name) { - final pathWithoutQuery = name.split(RegExp(r'[?#]')).first; - final extension = path.extension(pathWithoutQuery).replaceFirst('.', '').toLowerCase(); + final uri = Uri.tryParse(name); + final filePath = uri != null && uri.hasScheme && uri.hasAuthority ? uri.path : name; + final extension = path.extension(filePath).replaceFirst('.', '').toLowerCase(); return extension.length <= 10 ? extension : ''; } diff --git a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart index 6cfe9cc6..44d19ac2 100644 --- a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart +++ b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart @@ -31,12 +31,15 @@ import '../../../../shared/widgets/app_choice_segmented_control.dart'; import '../../../../shared/widgets/app_http_headers_editor.dart'; import '../../../../shared/widgets/app_number_input.dart'; import '../../../../shared/widgets/app_path_picker_field.dart'; -import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_tooltip.dart'; import '../../../../shared/widgets/app_toast.dart'; import '../../../../l10n/l10n.dart'; import '../../../../util/util.dart'; import '../../application/pending_create_task.dart'; +import '../../application/task_list_navigation.dart'; +import '../widgets/history_search_field.dart'; +import '../widgets/create_task_action_buttons.dart'; import '../widgets/resolve_file_tree.dart'; class CreateTaskWindowPage extends ConsumerStatefulWidget { @@ -86,17 +89,28 @@ class _CreateTaskWindowPageState extends ConsumerState { String _configuredDownloadDirectory = ''; String _fileDataUri = ''; bool _programmaticUrlChange = false; + String _lastUrlText = ''; @override void initState() { super.initState(); _urlController.addListener(_handleUrlChanged); WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; final task = widget.initialTask ?? ref.read(pendingCreateTaskProvider); _applyInitialTask(task); if (widget.initialTask == null) { ref.read(pendingCreateTaskProvider.notifier).clear(); } + if (widget.windowController == null && widget.initialTask == null) { + // Navigating to /create again reuses this page, so consume subsequent + // browser/share requests as well as the initial pending task. + ref.listenManual(pendingCreateTaskProvider, (_, next) { + if (next == null) return; + ref.read(pendingCreateTaskProvider.notifier).clear(); + _applyInitialTask(next); + }); + } if (task == null || _urlController.text.trim().isEmpty) { unawaited(_loadClipboardUrl()); } @@ -161,10 +175,15 @@ class _CreateTaskWindowPageState extends ConsumerState { } void _handleUrlChanged() { - if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) { - _fileDataUri = ''; + final urlText = _urlController.text; + if (urlText != _lastUrlText) { + _lastUrlText = urlText; + _renameController.clear(); + if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) { + _fileDataUri = ''; + } } - _recognizeMagnetUri(_urlController.text.trim()); + _recognizeMagnetUri(urlText.trim()); } void _applyInitialTask(CreateTask? task) { @@ -173,6 +192,13 @@ class _CreateTaskWindowPageState extends ConsumerState { final opts = task.opts; setState(() { if (req?.url.isNotEmpty == true) { + _fileDataUri = ''; + _renameController.clear(); + _httpMethod = 'GET'; + _httpBody = ''; + _replaceHttpHeaders(const {'User-Agent': '', 'Cookie': '', 'Referer': ''}); + _trackersController.clear(); + _protocolTab = 0; _urlController.text = req!.url; _initialRawUrl = req.rawUrl; _initialLabels = req.labels == null ? null : Map.of(req.labels!); @@ -202,9 +228,6 @@ class _CreateTaskWindowPageState extends ConsumerState { } } if (opts != null) { - if (opts.name.isNotEmpty) { - _renameController.text = opts.name; - } if (opts.path.isNotEmpty) { _directoryController.text = opts.path; } @@ -257,7 +280,12 @@ class _CreateTaskWindowPageState extends ConsumerState { final page = Scaffold( backgroundColor: palette.sideBg, child: Padding( - padding: EdgeInsets.only(top: AppWindowChrome.reservesHeaderInset ? AppDesignTokens.windowHeaderHeight : 0), + key: const ValueKey('create-task-safe-content'), + padding: EdgeInsets.only( + top: AppWindowChrome.reservesHeaderInset + ? AppDesignTokens.windowHeaderHeight + : MediaQuery.paddingOf(context).top, + ), child: Column( children: [ Container( @@ -300,7 +328,8 @@ class _CreateTaskWindowPageState extends ConsumerState { children: [ SizedBox( height: 120, - child: TextField( + child: AppTextField( + key: const ValueKey('create-task-url-input'), controller: _urlController, hintText: context.l10n.pasteDownloadLinks, keyboardType: TextInputType.multiline, @@ -346,18 +375,12 @@ class _CreateTaskWindowPageState extends ConsumerState { const SizedBox(height: 16), _FormRow( label: context.l10n.connections, - child: Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: AppDesignTokens.settingsNumberControlWidth, - child: AppNumberInput( - fieldKey: const ValueKey('create-task-connections-input'), - controller: _connectionsController, - min: 1, - max: 256, - hintText: context.l10n.enterCount, - ), - ), + child: AppNumberInput( + fieldKey: const ValueKey('create-task-connections-input'), + controller: _connectionsController, + min: 1, + max: 256, + hintText: context.l10n.enterCount, ), ), const SizedBox(height: 16), @@ -372,10 +395,6 @@ class _CreateTaskWindowPageState extends ConsumerState { pickerKey: const ValueKey('create-task-directory-picker'), controller: _directoryController, hintText: context.l10n.chooseDownloadDirectory, - filled: true, - border: Border.all(color: palette.border), - borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), onChanged: (_) { if (_asDefaultPath) { setState(() => _asDefaultPath = false); @@ -645,7 +664,7 @@ class _CreateTaskWindowPageState extends ConsumerState { label: context.l10n.trackers, child: SizedBox( height: 96, - child: TextField( + child: AppTextField( controller: _trackersController, hintText: context.l10n.oneTrackerPerLine, keyboardType: TextInputType.multiline, @@ -670,31 +689,27 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), ), - Container( - padding: const EdgeInsets.fromLTRB(24, 12, 24, 20), - decoration: BoxDecoration( - border: Border(top: BorderSide(color: palette.border)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SecondaryButton( - onPressed: _creating ? null : _closeWindow, - child: SizedBox(width: 68, child: Center(child: Text(context.l10n.cancel))), - ), - const SizedBox(width: 12), - AppPrimaryButton( - onPressed: _creating ? null : _confirm, - child: SizedBox( - width: 68, - child: Center( - child: _creating - ? const SizedBox.square(dimension: 14, child: CircularProgressIndicator()) - : Text(context.l10n.confirm), - ), + SafeArea( + top: false, + child: Container( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 20), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: palette.border)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CreateTaskActionButtons( + submitting: _creating, + onCancel: _closeWindow, + onSubmit: _confirm, + cancelLabel: context.l10n.cancel, + submitLabel: context.l10n.confirm, + cancelButtonKey: const ValueKey('create-task-cancel-button'), + submitButtonKey: const ValueKey('create-task-confirm-button'), ), - ), - ], + ], + ), ), ), ], @@ -713,6 +728,21 @@ class _CreateTaskWindowPageState extends ConsumerState { await windowManager.close(); } + Future _finishCreation() async { + if (widget.windowController != null) { + // Navigation is best-effort after a successful submission. A navigation + // failure must not leave a completed form available for resubmission. + try { + await ref.read(appCapabilitiesProvider).navigation.showDownloadingTasks(); + } catch (error) { + debugPrint('Unable to select downloading tasks: $error'); + } + } else { + ref.read(taskListNavigationProvider.notifier).showDownloading(); + } + await _closeWindow(); + } + Future _loadDefaults() async { try { final config = await ref.read(gopeedServiceProvider).getConfig(); @@ -757,15 +787,10 @@ class _CreateTaskWindowPageState extends ConsumerState { } Future _pickTorrentFile() async { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: const ['torrent'], - withData: kIsWeb, - ); - final file = result?.files.single; + final file = await FilePicker.pickFile(type: FileType.custom, allowedExtensions: const ['torrent']); if (file == null) return; - await _applyTorrentFile(name: file.name, filePath: file.path, bytes: file.bytes); + await _applyTorrentFile(name: file.name, filePath: file.path, readBytes: file.readAsBytes); } Future _handleDroppedTorrent(DropDoneDetails details) async { @@ -844,7 +869,7 @@ class _CreateTaskWindowPageState extends ConsumerState { ); }), ); - await _closeWindow(); + await _finishCreation(); return; } @@ -852,7 +877,6 @@ class _CreateTaskWindowPageState extends ConsumerState { final options = _buildOptions(); final result = await ref.read(gopeedServiceProvider).resolve(ResolveTask(req: request, opts: options)); if (!mounted) return; - _syncResolvedName(result); final created = await _showResolveDialog(request, result); if (!created) { if (mounted) { @@ -860,7 +884,7 @@ class _CreateTaskWindowPageState extends ConsumerState { } return; } - await _closeWindow(); + await _finishCreation(); } catch (error) { if (mounted) { _showToast(error.toString()); @@ -1137,19 +1161,15 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), actions: [ - SecondaryButton( - onPressed: submitting ? null : () => closeOverlay(dialogContext, false), - child: SizedBox(width: 68, child: Center(child: Text(dialogContext.l10n.cancel))), - ), - AppPrimaryButton( - onPressed: submitting ? null : submit, - child: SizedBox( - width: 68, - child: Center( - child: submitting - ? const SizedBox.square(dimension: 14, child: CircularProgressIndicator()) - : Text(dialogContext.l10n.create), - ), + flutter.Flexible( + child: CreateTaskActionButtons( + submitting: submitting, + onCancel: () => closeOverlay(dialogContext, false), + onSubmit: submit, + cancelLabel: dialogContext.l10n.cancel, + submitLabel: dialogContext.l10n.createAction, + cancelButtonKey: const ValueKey('resolve-cancel-button'), + submitButtonKey: const ValueKey('resolve-create-button'), ), ), ], @@ -1192,7 +1212,7 @@ class _CreateTaskWindowPageState extends ConsumerState { width: contentWidth, child: Row( children: [ - Expanded(child: Text(dialogContext.l10n.createHistory)), + Expanded(child: Text(dialogContext.l10n.historyLinks)), AppTooltip( message: dialogContext.l10n.clearHistory, child: GhostButton( @@ -1231,20 +1251,9 @@ class _CreateTaskWindowPageState extends ConsumerState { height: screenSize.height < 560 ? screenSize.height * 0.55 : 360, child: Column( children: [ - SizedBox( - height: 38, - child: TextField( - key: const ValueKey('create-history-filter'), - controller: filterController, - placeholder: Text( - dialogContext.l10n.searchHistory, - style: TextStyle(color: palette.searchHint, fontSize: 13), - ), - features: [ - InputFeature.leading(Icon(Icons.search_rounded, size: 15, color: palette.textMuted)), - ], - onChanged: (value) => setDialogState(() => filterQuery = value), - ), + HistorySearchField( + controller: filterController, + onChanged: (value) => setDialogState(() => filterQuery = value), ), const SizedBox(height: 10), Expanded( @@ -1312,12 +1321,6 @@ class _CreateTaskWindowPageState extends ConsumerState { filterController.dispose(); } - void _syncResolvedName(ResolveResult result) { - if (_renameController.text.trim().isEmpty && result.res.name.trim().isNotEmpty) { - _renameController.text = result.res.name; - } - } - void _setUrlText(String value, {String fileDataUri = ''}) { _programmaticUrlChange = true; _urlController.text = value; @@ -1659,17 +1662,12 @@ class _WindowTextField extends StatelessWidget { @override Widget build(BuildContext context) { - final palette = AppPalette.of(context); - return TextField( + return AppTextField( controller: controller, hintText: hintText, keyboardType: keyboardType, inputFormatters: inputFormatters, obscureText: obscureText, - filled: true, - border: Border.all(color: palette.border), - borderRadius: BorderRadius.circular(4), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), ); } } diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/create_task_action_buttons.dart b/ui/flutter/lib/features/tasks/presentation/widgets/create_task_action_buttons.dart new file mode 100644 index 00000000..6de2ce88 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/create_task_action_buttons.dart @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart' as flutter; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/widgets/app_loading_button.dart'; + +class CreateTaskActionButtons extends StatelessWidget { + const CreateTaskActionButtons({ + super.key, + required this.submitting, + required this.onCancel, + required this.onSubmit, + required this.cancelLabel, + required this.submitLabel, + this.cancelButtonKey, + this.submitButtonKey, + }); + + final bool submitting; + final VoidCallback onCancel; + final VoidCallback onSubmit; + final String cancelLabel; + final String submitLabel; + final Key? cancelButtonKey; + final Key? submitButtonKey; + + @override + Widget build(BuildContext context) { + return flutter.Row( + mainAxisSize: MainAxisSize.min, + children: [ + SecondaryButton( + key: cancelButtonKey, + onPressed: submitting ? null : onCancel, + child: SizedBox( + width: _actionContentWidth, + child: Center(child: Text(cancelLabel)), + ), + ), + const SizedBox(width: AppDesignTokens.space12), + AppLoadingButton( + key: submitButtonKey, + onPressed: onSubmit, + loading: submitting, + variant: AppLoadingButtonVariant.primary, + child: SizedBox( + width: _actionContentWidth, + child: Center(child: Text(submitLabel)), + ), + ), + ], + ); + } +} + +const _actionContentWidth = 68.0; diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/history_search_field.dart b/ui/flutter/lib/features/tasks/presentation/widgets/history_search_field.dart new file mode 100644 index 00000000..b46485f0 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/history_search_field.dart @@ -0,0 +1,38 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_text_field.dart'; + +class HistorySearchField extends StatelessWidget { + const HistorySearchField({super.key, required this.controller, required this.onChanged}); + + final TextEditingController controller; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + const textStyle = TextStyle(fontSize: 13); + return ConstrainedBox( + // Allow large accessibility text to grow instead of clipping the line. + constraints: const BoxConstraints(minHeight: 38), + child: AppTextField( + key: const ValueKey('create-history-filter'), + controller: controller, + style: textStyle, + padding: AppDesignTokens.compactTextFieldPadding, + placeholder: Text( + context.l10n.searchHistory, + // Match the editable text metrics so the cursor and hint align. + style: textStyle.copyWith(color: palette.searchHint), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + features: [InputFeature.leading(Icon(Icons.search_rounded, size: 15, color: palette.textMuted))], + onChanged: onChanged, + ), + ); + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart index 60e88399..14eeccf7 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart @@ -61,6 +61,8 @@ class _ResolveFileTreeState extends State { child: FileTreeView( items: _treeItems, keyPrefix: 'resolve-tree', + rowHeight: _resolveTreeRowHeight, + contentTextStyle: _resolveFileNameStyle, headerLeading: ExcludeFocus( child: Checkbox( state: allSelected @@ -187,42 +189,48 @@ class _TreeFooter extends StatelessWidget { @override Widget build(BuildContext context) { final palette = AppPalette.of(context); - final filters = _FileTypeFilterGroup( - videoActive: activeTypeFilters.contains(_FileTypeFilter.video), - audioActive: activeTypeFilters.contains(_FileTypeFilter.audio), - imageActive: activeTypeFilters.contains(_FileTypeFilter.image), - onSelectVideo: onSelectVideo, - onSelectAudio: onSelectAudio, - onSelectImage: onSelectImage, + final filters = KeyedSubtree( + key: const ValueKey('resolve-tree-filters'), + child: _FileTypeFilterGroup( + videoActive: activeTypeFilters.contains(_FileTypeFilter.video), + audioActive: activeTypeFilters.contains(_FileTypeFilter.audio), + imageActive: activeTypeFilters.contains(_FileTypeFilter.image), + onSelectVideo: onSelectVideo, + onSelectAudio: onSelectAudio, + onSelectImage: onSelectImage, + ), ); - final stats = Row( - mainAxisSize: MainAxisSize.min, + final statsStyle = TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w600); + final selectedLabel = context.l10n.selectedCount(selectedCount, totalCount); + final sizeLabel = unknownSize ? context.l10n.unknownSize : Util.fmtByte(selectedSize); + final baseline = MediaQuery.textScalerOf(context).scale(statsStyle.fontSize!); + final stats = Wrap( + key: const ValueKey('resolve-tree-selection-stats'), + alignment: WrapAlignment.end, + spacing: AppDesignTokens.space8, + runSpacing: AppDesignTokens.space4, + crossAxisAlignment: WrapCrossAlignment.start, children: [ - Text( - context.l10n.selectedCount(selectedCount, totalCount), - style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w600), + Baseline( + baseline: baseline, + baselineType: TextBaseline.alphabetic, + child: Text(key: const ValueKey('resolve-tree-selected-count'), selectedLabel, style: statsStyle), ), - const SizedBox(width: 8), - Text( - unknownSize ? context.l10n.unknownSize : Util.fmtByte(selectedSize), - style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w600), + Baseline( + baseline: baseline, + baselineType: TextBaseline.alphabetic, + child: Text(key: const ValueKey('resolve-tree-selected-size'), sizeLabel, style: statsStyle), ), ], ); - return LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth < 360) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Align(alignment: Alignment.centerLeft, child: filters), - const SizedBox(height: 4), - Align(alignment: Alignment.centerRight, child: stats), - ], - ); - } - return Row(crossAxisAlignment: CrossAxisAlignment.center, children: [filters, const Spacer(), stats]); - }, + + return OverflowBar( + key: const ValueKey('resolve-tree-footer'), + spacing: AppDesignTokens.space8, + alignment: MainAxisAlignment.spaceBetween, + overflowAlignment: OverflowBarAlignment.end, + overflowSpacing: AppDesignTokens.space4, + children: [filters, stats], ); } } @@ -288,7 +296,11 @@ class _FilterDivider extends StatelessWidget { @override Widget build(BuildContext context) { - return SizedBox(width: 1, height: 18, child: ColoredBox(color: color)); + return SizedBox( + width: _fileTypeFilterDividerWidth, + height: 18, + child: ColoredBox(color: color), + ); } } @@ -303,7 +315,7 @@ class _FilterButton extends StatelessWidget { Widget build(BuildContext context) { final palette = AppPalette.of(context); return SizedBox( - width: 40, + width: _fileTypeFilterButtonWidth, height: 28, child: DecoratedBox( decoration: BoxDecoration(color: active ? palette.filterActiveBg : null), @@ -336,3 +348,7 @@ extension on _FileTypeFilter { const _videoExts = {'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'}; const _audioExts = {'mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'ape'}; const _imageExts = {'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'}; +const _resolveTreeRowHeight = 36.0; +const _resolveFileNameStyle = TextStyle(fontSize: 14, height: 1.25); +const _fileTypeFilterButtonWidth = 40.0; +const _fileTypeFilterDividerWidth = 1.0; diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_desktop.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_desktop.dart index 1dded739..004092bd 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_desktop.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_desktop.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart' show Colors, Icons; import 'package:flutter/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; +import '../../../../../core/window/app_window_chrome.dart'; import '../../../../../shared/theme/app_design_tokens.dart'; import '../../../../../shared/theme/app_palette.dart'; import '../../../domain/task_record.dart'; @@ -224,7 +225,7 @@ class _ProgressContent extends StatelessWidget { fillColor: fillColor, highlightStartColor: fillColor, highlightEndColor: fillColor, - shimmer: task.status == TaskStatus.downloading, + shimmer: task.status == TaskStatus.downloading && !task.waiting, ), const SizedBox(height: 6), TaskCardFooter(task: task, isError: isError), @@ -237,7 +238,10 @@ class _ProgressContent extends StatelessWidget { TaskStatus.downloading => [(Icons.pause, onPause), (Icons.close, onDelete)], TaskStatus.paused => [(Icons.play_arrow, onResume), (Icons.close, onDelete)], TaskStatus.failed => [(Icons.refresh, onResume), (Icons.delete_outline, onDelete)], - TaskStatus.completed => [(Icons.folder_open_outlined, null), (Icons.delete_outline, onDelete)], + TaskStatus.completed => [ + if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, null), + (Icons.delete_outline, onDelete), + ], }; return actions @@ -306,7 +310,10 @@ class _CompletedContent extends StatelessWidget { TaskStatus.downloading => [(Icons.pause, null), (Icons.close, onDelete)], TaskStatus.paused => [(Icons.play_arrow, null), (Icons.close, onDelete)], TaskStatus.failed => [(Icons.refresh, null), (Icons.delete_outline, onDelete)], - TaskStatus.completed => [(Icons.folder_open_outlined, onReveal), (Icons.delete_outline, onDelete)], + TaskStatus.completed => [ + if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, onReveal), + (Icons.delete_outline, onDelete), + ], }; return actions diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_mobile.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_mobile.dart index 2700956b..8724f0d0 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_mobile.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_mobile.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' show Colors, Icons; import 'package:flutter/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; +import '../../../../../core/window/app_window_chrome.dart'; import '../../../../../shared/theme/app_design_tokens.dart'; import '../../../../../shared/theme/app_palette.dart'; import '../../../domain/task_record.dart'; @@ -148,7 +149,7 @@ class _TaskCardMobileState extends State { fillColor: fillColor, highlightStartColor: fillColor, highlightEndColor: fillColor, - shimmer: task.status == TaskStatus.downloading, + shimmer: task.status == TaskStatus.downloading && !task.waiting, ), const SizedBox(height: 6), TaskCardFooter(task: task, isError: task.status == TaskStatus.failed), @@ -171,7 +172,10 @@ class _TaskCardMobileState extends State { TaskStatus.downloading => [(Icons.pause, widget.onPause), (Icons.close, widget.onDelete)], TaskStatus.paused => [(Icons.play_arrow, widget.onResume), (Icons.close, widget.onDelete)], TaskStatus.failed => [(Icons.refresh, widget.onResume), (Icons.delete_outline, widget.onDelete)], - TaskStatus.completed => [(Icons.folder_open_outlined, widget.onReveal), (Icons.delete_outline, widget.onDelete)], + TaskStatus.completed => [ + if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, widget.onReveal), + (Icons.delete_outline, widget.onDelete), + ], }; return actions diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart index 19b34abe..51c27537 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart @@ -95,14 +95,18 @@ class TaskCardFooter extends StatelessWidget { children: [ Expanded( child: Text( - task.total != null ? '${task.downloaded} / ${task.total}' : context.l10n.unknownSize, + task.total != null + ? '${task.downloaded} / ${task.total}' + : task.downloadedBytes == 0 + ? context.l10n.unknownSize + : task.downloaded, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: leftColor, fontSize: 10, height: 1), ), ), const SizedBox(width: 12), - if (task.speed != null && !isError) ...[ + if (task.speed != null) ...[ _TaskTransferSpeed(icon: Icons.south, value: task.speed!, color: metaColor), const SizedBox(width: 8), TickDivider(color: palette.progressTrack), diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_create_button.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_create_button.dart new file mode 100644 index 00000000..465b7c2c --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_create_button.dart @@ -0,0 +1,42 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; + +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/widgets/app_primary_button.dart'; + +/// Shared creation action for task headers and empty-list guidance. +class TaskCreateButton extends StatelessWidget { + const TaskCreateButton({super.key, required this.onPressed, this.minHeight = 0, this.compact = false}); + + final VoidCallback onPressed; + final double minHeight; + final bool compact; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints(minHeight: minHeight), + child: AppPrimaryButton( + onPressed: onPressed, + size: compact ? shad.ButtonSize.small : shad.ButtonSize.normal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, size: compact ? 16 : null), + SizedBox(width: compact ? AppDesignTokens.space4 : AppDesignTokens.space8), + Flexible( + child: Text( + context.l10n.create, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: compact ? const TextStyle(fontSize: 13) : null, + ), + ), + ], + ), + ), + ); + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart index 0e6f6f57..49de9689 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb; -import 'package:flutter/material.dart' show Colors, Icons; +import 'package:flutter/material.dart' show Colors, Icons, SelectionArea; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; @@ -9,6 +9,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_copy_icon_button.dart'; +import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_tooltip.dart'; import '../../../../shared/widgets/detail/app_detail_surface.dart'; import '../../../../core/utils/text_wrap.dart'; @@ -121,7 +122,7 @@ class _TaskDetailsViewState extends ConsumerState { ? AppDesignTokens.taskDetailsMobilePadding : AppDesignTokens.taskDetailsDesktopPadding; final runtimeStatus = _tabIndex == 1 ? ref.watch(taskRuntimeStatusProvider(widget.task.id)).value : null; - return Column( + final content = Column( children: [ Container( height: 48, @@ -203,6 +204,7 @@ class _TaskDetailsViewState extends ConsumerState { ), ], ); + return SelectionArea(key: const ValueKey('task-details-selection-area'), child: content); } bool get _canOpenStoragePath { @@ -512,7 +514,7 @@ class _EditableUrlBlock extends StatelessWidget { children: [ SizedBox( height: 76, - child: shad.TextField( + child: AppTextField( controller: controller, minLines: null, maxLines: null, diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_create_hint.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_create_hint.dart new file mode 100644 index 00000000..0ddd8189 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_create_hint.dart @@ -0,0 +1,49 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_tooltip.dart'; + +/// Keeps the action inside the localized sentence, including RTL word order. +class TaskEmptyCreateHint extends StatelessWidget { + const TaskEmptyCreateHint({super.key, required this.onCreateTask}); + + final VoidCallback onCreateTask; + + @override + Widget build(BuildContext context) { + const actionMarker = '\uFFFC'; + final parts = context.l10n.emptyTaskCreateHint(actionMarker).split(actionMarker); + return Text.rich( + TextSpan( + children: [ + TextSpan(text: parts.first.trimRight()), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: AppTooltip( + message: context.l10n.create, + child: Semantics( + label: context.l10n.create, + child: SizedBox.square( + dimension: 30, + child: IconButton.outline( + key: const ValueKey('tasks-empty-create-button'), + density: ButtonDensity.compact, + onPressed: onCreateTask, + icon: const Icon(Icons.add, size: 16), + ), + ), + ), + ), + ), + ), + TextSpan(text: parts.last.trimLeft()), + ], + ), + textAlign: TextAlign.center, + style: TextStyle(color: AppPalette.of(context).textMuted, fontSize: 13), + ); + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_state.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_state.dart index 73b7f816..be086dd6 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_state.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_empty_state.dart @@ -1,35 +1,45 @@ import 'package:flutter/widgets.dart'; import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import 'task_empty_create_hint.dart'; import '../../../../l10n/l10n.dart'; class TaskEmptyState extends StatelessWidget { - const TaskEmptyState({super.key, this.message}); + const TaskEmptyState({super.key, this.message, this.onCreateTask}); final String? message; + final VoidCallback? onCreateTask; @override Widget build(BuildContext context) { final palette = AppPalette.of(context); return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 360), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CustomPaint( - key: const ValueKey('task-empty-illustration'), - size: const Size(148, 108), - painter: _TaskEmptyIllustrationPainter(palette: palette), - ), - const SizedBox(height: 18), - Text( - message ?? context.l10n.emptyTaskList, - style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700), - ), - ], + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CustomPaint( + key: const ValueKey('task-empty-illustration'), + size: const Size(148, 108), + painter: _TaskEmptyIllustrationPainter(palette: palette), + ), + const SizedBox(height: 18), + Text( + message ?? context.l10n.emptyTaskList, + textAlign: TextAlign.center, + style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700), + ), + if (onCreateTask != null) ...[ + const SizedBox(height: AppDesignTokens.space24), + TaskEmptyCreateHint(onCreateTask: onCreateTask!), + ], + ], + ), ), ), ), diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_file_manager.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_manager.dart index 76847070..84e4f282 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_file_manager.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_manager.dart @@ -109,7 +109,7 @@ class _TaskFileManagerViewState extends State { try { final renderBox = actionContext.findRenderObject(); final origin = renderBox is RenderBox ? renderBox.localToGlobal(Offset.zero) & renderBox.size : null; - await Share.shareXFiles([XFile(_localPath(entry))], sharePositionOrigin: origin); + await SharePlus.instance.share(ShareParams(files: [XFile(_localPath(entry))], sharePositionOrigin: origin)); } catch (error) { if (mounted) showAppToast(context, error.toString(), type: AppToastType.error); } diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_file_tree.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_tree.dart index 489e2aa8..b50655e4 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_file_tree.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_tree.dart @@ -145,7 +145,7 @@ class _TaskDownloadProgress extends StatelessWidget { key: const ValueKey('task-files-total-progress'), value: progress, indeterminate: runtime == null && task.isIndeterminate, - shimmer: task.status == TaskStatus.downloading, + shimmer: task.status == TaskStatus.downloading && !task.waiting, height: 4, trackColor: palette.progressTrack, fillColor: palette.brandProgress, diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_update_url_dialog.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_update_url_dialog.dart index 33a3bc82..9dd7c6ff 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_update_url_dialog.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_update_url_dialog.dart @@ -4,6 +4,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row; import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_http_headers_editor.dart'; import '../../../../shared/widgets/app_primary_button.dart'; +import '../../../../shared/widgets/app_text_field.dart'; import '../../../../l10n/l10n.dart'; import '../../domain/task_record.dart'; @@ -47,7 +48,7 @@ Future showTaskUpdateUrlDialog(BuildContext context, TaskRecord children: [ Text(dialogContext.l10n.downloadLink, style: TextStyle(color: palette.textPrimary, fontSize: 12)), const SizedBox(height: 6), - TextField(controller: urlController), + AppTextField(controller: urlController), const SizedBox(height: 18), AppHttpHeadersEditor( controller: headersController, diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index 3569cc64..947af41b 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -18,6 +18,7 @@ "exit": "خروج", "pressBackAgainToExit": "اضغط زر الرجوع مرة أخرى للخروج", "create": "إنشاء مهمة", + "createAction": "إنشاء", "directDownload": "تنزيل مباشر", "advancedOptions": "خيارات متقدمة", "followSettings": "اتباع الإعدادات", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "حفظ كمجلد التنزيل الافتراضي", "downloadDirValid": "الرجاء تحديد مجلد التنزيل", "connections": "الاتصالات", + "maxRetries": "الحد الأقصى لإعادة المحاولات", "useServerCtime": "استخدام وقت الخادم لإنشاء الملف", "maxRunning": "الحد الأقصى للمهام المتزامنة", "defaultDirectDownload": "تفعيل التنزيل المباشر افتراضياً", @@ -102,6 +104,7 @@ "extensions": "الإضافات", "extensionInstallUrl": "عنوان التثبيت", "extensionInstallSuccess": "تم التثبيت بنجاح", + "extensionUpdateConfirm": "هل تريد تحديث «{name}» إلى أحدث إصدار؟", "extensionUpdateSuccess": "تم التحديث بنجاح", "extensionDelete": "حذف الإضافة", "extensionAlreadyLatest": "هذا هو أحدث إصدار بالفعل", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count}", "resourceHasNoFiles": "ولا توجد في هذا المورد قائمة ملف قابلة للاختيار", "noFiles": "لا توجد ملفات", - "createHistory": "تاريخ الخلق", + "historyLinks": "روابط السجل", "noHistory": "لا تاريخ", "directDownloadDescription": "حل المشاكل وخلق المهام على الفور", "taskProgress": "التقدم المحرز", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "لا يدعم إصدار الويب تعديل إعدادات API مباشرة. عدّل خيارات بدء تشغيل الخادم أو ملف الإعدادات، ثم أعد تشغيل الخدمة لتطبيق التغييرات.", "webMcpApiTokenHint": "إذا قمت بتكوين رمز API، فيجب تمرير معلومات المصادقة عند ربط الوكيل", "useMcpApiTokenTemplate": "التبديل إلى إعدادات المصادقة", - "mcpRequiresTcp": "تتوفر إرشادات ربط الوكيل عندما يكون بروتوكول API هو TCP." + "mcpRequiresTcp": "تتوفر إرشادات ربط الوكيل عندما يكون بروتوكول API هو TCP.", + "emptyTaskCreateHint": "اضغط على {action} لبدء التنزيل" } diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb new file mode 100644 index 00000000..39c351b9 --- /dev/null +++ b/ui/flutter/lib/l10n/app_az.arb @@ -0,0 +1,633 @@ +{ + "@@locale": "az", + "label": "Azərbaycan dili", + "error": "Xəta", + "tip": "Məsləhət", + "confirm": "Təsdiqlə", + "confirmDelete": "Silmək istədiyinizə əminsiniz?", + "cancel": "Ləğv et", + "on": "Açıq", + "off": "Qapalı", + "selectAll": "Hamısını seç", + "select": "Seç", + "task": "Tapşırıqlar", + "downloading": "Endirilir", + "downloaded": "Endirilib", + "setting": "Parametrlər", + "donate": "Bağış et", + "exit": "Çıxış", + "pressBackAgainToExit": "Çıxmaq üçün yenidən geri düyməsinə basın", + "create": "Tapşırıq yarat", + "createAction": "Yarat", + "directDownload": "Birbaşa Endirmə", + "advancedOptions": "Əlavə Seçimlər", + "followSettings": "Parametrlərə uyğun", + "downloadLink": "Endirmə Keçidi", + "downloadLinkValid": "Zəhmət olmasa endirmə keçidini daxil edin", + "downloadLinkHit": "Zəhmət olmasa endirmə keçidini daxil edin, hər sətirdə bir dənə@append", + "downloadLinkHitDesktop": ", və ya torrent faylını birbaşa buraya sürükləyin", + "download": "Endir", + "noFileSelected": "Davam etmək üçün ən azı bir fayl seçin", + "noStoragePermission": "Yaddaş icazəsi tələb olunur", + "selectFile": "Fayl Seç", + "rename": "Adını dəyiş", + "basic": "Əsas", + "advanced": "Əlavə", + "general": "Ümumi", + "downloadDir": "Endirmə Qovluğu", + "rememberLastDownloadDir": "Standart endirmə qovluğu kimi yadda saxla", + "downloadDirValid": "Zəhmət olmasa endirmə qovluğunu seçin", + "connections": "Əlaqələr", + "maxRetries": "Maksimum cəhd sayı", + "useServerCtime": "Fayl yaratma tarixi üçün server vaxtından istifadə et", + "maxRunning": "Paralel Endirmələr", + "defaultDirectDownload": "Standart olaraq Birbaşa Endirmə", + "autoStartTasks": "Başlanğıcda tamamlanmamış tapşırıqları avtomatik başlat", + "autoTorrentEnable": ".torrent fayllarından avtomatik BT tapşırığı yarat", + "autoTorrentDeleteAfterDownload": "BT tapşırığı yaradıldıqdan sonra .torrent faylını sil", + "autoDeleteMissingFileTasks": "Faylı tapılmayan tapşırıqları avtomatik sil", + "items": "{count} element", + "@items": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "subscribeTracker": "Trackerə Abunə Ol", + "subscribeFail": "Abunəlik alınmadı, şəbəkəni yoxlayın və ya sonra yenidən cəhd edin", + "update": "Yenilə", + "updateDaily": "Gündəlik yenilə", + "lastUpdate": "Son yenilənmə: @time", + "addTracker": "Tracker Əlavə Et", + "addTrackerHit": "Zəhmət olmasa tracker server ünvanını daxil edin, hər sətirdə bir dənə", + "ui": "İnterfeys", + "theme": "Tema", + "themeSystem": "Sistem", + "themeLight": "Açıq", + "themeDark": "Tünd", + "locale": "Dil", + "notifyWhenNewVersion": "Yeniləmələr barədə bildiriş göndər", + "analyticsEnabled": "İstifadə Analitikası", + "analyticsEnabledDesc": "Təkmilləşdirməyə kömək etmək üçün anonim istifadə məlumatlarını paylaşın", + "about": "Haqqında", + "homepage": "Əsas Səhifə", + "version": "Versiya", + "protocol": "Protokol", + "port": "Port", + "apiToken": "API Açarı", + "notSet": "TƏYİN EDİLMƏYİB", + "set": "TƏYİN ET", + "portInUse": "Port [@port] artıq istifadədədir, zəhmət olmasa portu dəyişin", + "effectAfterRestart": "Yenidən başladıldıqdan sonra qüvvəyə minir", + "developer": "Tərtibatçı", + "logDirectory": "Loq Qovluğu", + "webhook": "Webhook", + "webhookEnable": "Webhook-ları Aktivləşdir", + "webhookDesc": "Tapşırıqlar tamamlandıqda və ya uğursuz olduqda HTTP POST bildirişləri göndər", + "webhookUrlHint": "Webhook URL-ini daxil edin", + "webhookTest": "Test", + "webhookTestSuccess": "Webhook testi uğurlu oldu", + "webhookTestFail": "Webhook testi uğursuz oldu", + "script": "Skript", + "scriptEnable": "Skriptləri Aktivləşdir", + "scriptDesc": "Endirmələr uğurla tamamlandıqda fərdi skriptləri icra et", + "scriptPathHint": "Skript faylının yolunu daxil edin (məs., /path/to/script.sh)", + "urlInvalid": "Zəhmət olmasa düzgün HTTP və ya HTTPS URL-i daxil edin", + "required": "Bu sahə mütləqdir", + "show": "Göstər", + "continueAction": "Davam Et", + "pause": "Fasilə ver", + "startAll": "Hamısını Başlat", + "pauseAll": "Hamısına Fasilə Ver", + "deleteTask": "@count tapşırığı sil", + "deleteTaskTip": "Endirilmiş faylları saxla", + "delete": "Sil", + "add": "Əlavə et", + "edit": "Redaktə et", + "newVersionTitle": "Yeni versiya @version tapıldı", + "newVersionUpdate": "İndi Yenilə", + "newVersionLater": "Sonra", + "extensions": "Uzantılar", + "extensionInstallUrl": "Quraşdırma URL-i", + "extensionInstallSuccess": "Uğurla quraşdırıldı", + "extensionUpdateConfirm": "“{name}” ən son versiyaya yenilənsin?", + "extensionUpdateSuccess": "Uğurla yeniləndi", + "extensionDelete": "Uzantını Sil", + "extensionAlreadyLatest": "Artıq son versiyadır", + "extensionFind": "Uzantı Tap", + "extensionDevelop": "Uzantı Hazırla", + "extensionStoreTitle": "Uzantı Mağazası", + "extensionStoreSection": "Mağaza Uzantıları", + "extensionInstalledSection": "Quraşdırılmış Uzantılar", + "extensionInstalledEmpty": "Hələ heç bir uzantı quraşdırılmayıb", + "extensionStoreEmpty": "Uyğun uzantı tapılmadı", + "extensionSearchHint": "Ada, başlığa, təsvirə və ya müəllifə görə axtar", + "extensionSortStars": "Ən Çox Ulduzlu", + "extensionSortInstalls": "Ən Çox Quraşdırılan", + "extensionSortUpdated": "Son Yenilənən", + "extensionSortToggle": "Sıralama istiqamətini dəyiş", + "extensionLoadMore": "Daha çox yüklə", + "extensionNoMore": "Başqa uzantı yoxdur", + "extensionInstalled": "Quraşdırılıb", + "extensionCanUpdate": "Yeniləmə Mövcuddur", + "extensionInstall": "Quraşdır", + "extensionLoadLocal": "Lokal Qovluqdan Quraşdır", + "extensionFilterAll": "Hamısı", + "extensionFilterMarket": "Mağaza", + "extensionFilterInstalled": "Quraşdırılıb", + "extensionManualInstall": "Əl ilə Quraşdırma", + "extensionInstallTools": "Quraşdırma Seçimləri", + "history": "Tarixçə", + "clearHistory": "Tarixçəni Təmizlə", + "noHistoryFound": "Tarixçə Tapılmadı", + "serviceTitle": "Endirmə Xidməti", + "serviceText": "İşləyir", + "network": "Şəbəkə", + "proxy": "Proksi", + "noProxy": "Proksi Yoxdur", + "systemProxy": "Sistem Proksisi", + "customProxy": "Fərdi Proksi", + "server": "Server", + "username": "İstifadəçi adı", + "password": "Parol", + "thanks": "Təşəkkürlər", + "thanksDesc": "Gopeed icmasının qurulmasına və inkişafına kömək edən bütün töhfəçilərə təşəkkürlər!", + "browserExtension": "Brauzer Uzantısı", + "launchAtStartup": "Başlanğıcda İşə Sal", + "runAsMenubarApp": "Menyu Çubuğu Tətbiqi kimi İşlə", + "runAsMenubarAppDesc": "Dock ikonasını gizlət və yalnız menyu çubuğunda işlə", + "seedConfig": "Seed Parametrləri", + "seedKeep": "Əl ilə dayandırılana qədər seed etməyə davam et", + "seedRatio": "Seed nisbəti", + "seedTime": "Seed müddəti (dəqiqə)", + "setAsDefaultBtClient": "Standart BT müştərisi kimi təyin et", + "taskDetail": "Tapşırıq Detalları", + "taskName": "Tapşırığın Adı", + "taskUrl": "Tapşırığın URL-i", + "downloadPath": "Endirmə Yolu", + "skipVerifyCert": "Sertifikat Yoxlamasını Ötür", + "archives": "Arxivlər", + "autoExtract": "Arxivləri Avtomatik Çıxar", + "archivePassword": "Arxiv Parolu", + "archivePasswordHint": "Parolla qorunmayıbsa boş buraxın", + "deleteAfterExtract": "Çıxarıldıqdan sonra arxivi sil", + "extracting": "Çıxarılır", + "extractDone": "Çıxarma tamamlandı", + "extractError": "Çıxarma uğursuz oldu", + "waitingParts": "Parçalar gözlənilir", + "name": "Ad", + "size": "Ölçü", + "unknown": "Naməlum", + "fileSelectedCount": "Fayllar: ", + "fileSelectedSize": "Ölçü: ", + "httpHeader": "Başlıq", + "httpHeaderName": "Başlığın Adı", + "httpHeaderValue": "Başlığın Dəyəri", + "login": "Giriş", + "username_required": "Zəhmət olmasa istifadəçi adınızı daxil edin", + "password_required": "Zəhmət olmasa parolunuzu daxil edin", + "login_success": "Giriş uğurlu oldu", + "login_failed": "Giriş uğursuz oldu, istifadəçi adınızı və parolunuzu yoxlayın", + "login_failed_network": "Giriş uğursuz oldu, şəbəkə əlaqənizi yoxlayın", + "insertPlaceholder": "Yer Tutucu Əlavə Et", + "placeholderYear": "Cari il", + "placeholderMonth": "Cari ay (01-12)", + "placeholderDay": "Cari gün (01-31)", + "placeholderDate": "Tam tarix (YYYY-MM-DD)", + "example": "məs. @value", + "downloadCategories": "Endirmə Kateqoriyaları", + "categoryMusic": "Musiqi", + "categoryVideo": "Video", + "categoryDocument": "Sənəd", + "categoryProgram": "Proqram", + "categoryName": "Kateqoriyanın Adı", + "categoryPath": "Kateqoriyanın Yolu", + "builtInCategory": "Daxili kateqoriya silinə bilməz", + "selectCategory": "Kateqoriya Seç", + "quickDirectories": "Sürətli qovluqlar", + "githubMirror": "GitHub Güzgüsü", + "githubMirrorEnable": "GitHub Güzgüsünü Aktivləşdir", + "githubMirrorDesc": "GitHub məzmununun endirilməsini sürətləndirmək üçün güzgülərdən istifadə et", + "githubMirrorType": "Güzgü Növü", + "githubMirrorUrl": "Güzgü URL-i", + "githubMirrorUrlHint": "Güzgü URL-ini daxil edin", + "updateUrl": "Yeniləmə URL-i", + "updateUrlManual": "Əl ilə Yeniləmə", + "updateUrlListen": "Yeniləməni Dinlə", + "updateUrlListeningTip": "URL yeniləməsi gözlənilir", + "updateUrlCancelListen": "Dinləməni Ləğv Et", + "updateUrlDialogHint": "Yeni endirmə URL-ini daxil edin", + "pendingUpdateFound": "Gözləyən Yeniləmə Tapşırığı Tapıldı", + "pendingUpdateConfirm": "\"{name}\" tapşırığı URL yeniləməsini gözləyir. Onu yeni URL ilə yeniləmək istəyirsiniz?", + "@pendingUpdateConfirm": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pendingUpdateYes": "Tapşırığı Yenilə", + "pendingUpdateNo": "Yeni Tapşırıq Yarat", + "ed2k": "ED2K", + "ed2kTcpPort": "TCP Dinləmə Portu", + "ed2kUdpPort": "UDP Dinləmə Portu", + "ed2kServerList": "Server Ünvanları", + "ed2kServerMet": "Server.met Mənbələri", + "ed2kNodesDat": "nodes.dat Mənbələri", + "ed2kAutoPort": "Avtomatik təyin et", + "ed2kOnePerLine": "Hər sətirdə bir dənə", + "ed2kServersHint": "host:port, hər sətirdə bir dənə", + "ed2kServerMetHint": "Server.met URL-i və ya ed2k server siyahısı keçidi, hər sətirdə bir dənə", + "ed2kNodesDatHint": "nodes.dat yolu və ya URL-i, hər sətirdə bir dənə", + "desktopNotification": "Masaüstü Bildirişləri", + "backgroundLocationKeepAlive": "Fon Endirmələri", + "notificationTaskDone": "Tapşırıq tamamlandı", + "notificationTaskError": "Tapşırıq xətası", + "appTitle": "Gopeed Endirmə Meneceri", + "unsupportedWindowPayload": "Dəstəklənməyən pəncərə sorğusu", + "runtimeInitializationFailed": "Gopeed runtime-ını işə salmaq mümkün olmadı.\n{error}", + "save": "Yadda saxla", + "retry": "Yenidən cəhd et", + "close": "Bağla", + "back": "Geri", + "open": "Aç", + "openFile": "Faylı Aç", + "share": "Paylaş", + "copy": "Kopyala", + "copied": "Kopyalandı", + "refresh": "Yenilə", + "status": "Vəziyyət", + "createdAt": "Yaradılıb", + "speed": "Sürət", + "uploadSpeed": "Yükləmə sürəti", + "remaining": "Qalıb", + "downloadDuration": "Endirmə müddəti", + "progress": "İrəliləyiş", + "failed": "Uğursuz", + "completed": "Tamamlandı", + "waiting": "Gözləyir", + "taskFailed": "Tapşırıq uğursuz oldu", + "unknownSize": "Naməlum ölçü", + "secondsRemaining": "{count} saniyə qalır", + "minutesRemaining": "{count} dəqiqə qalır", + "hoursRemaining": "{count} saat qalır", + "secondsDuration": "{count} saniyə", + "minutesDuration": "{count} dəqiqə", + "hoursDuration": "{count} saat", + "followSystem": "Sistem Standartı", + "basicSettings": "Əsas", + "downloadSettings": "Endirmələr", + "advancedSettings": "Əlavə", + "themeColor": "Vurğu Rəngi", + "themeColorDescription": "Tətbiqin vurğu rəngini fərdiləşdirin", + "browserExtensionDescription": "Quraşdırdıqdan sonra brauzer endirmələrini birbaşa Gopeed-ə göndər", + "desktopNotificationDescription": "Tapşırıq tamamlandıqda və ya uğursuz olduqda sistem bildirişi göstər", + "backgroundLocationKeepAliveDescription": "Fonda endirmələri aktiv saxlamaq üçün məkan girişindən istifadə et", + "currentVersion": "Cari versiya {version}", + "contributors": "Töhfəçilər", + "viewContributors": "Töhfəçilərə bax", + "maxRunningDescription": "Eyni anda endirilə biləcək maksimum tapşırıq sayı", + "defaultUserAgentDescription": "HTTP və HTTPS endirmələri üçün standart User-Agent", + "listenPort": "Dinləmə Portu", + "onePerLine": "Hər sətirdə bir dənə", + "proxyProtocol": "Protokol", + "restartRequired": "Bu parametr tətbiq yenidən başladıldıqdan sonra qüvvəyə minir", + "socketPath": "Soket Yolu", + "listenAddress": "Dinləmə Ünvanı", + "currentConnection": "Cari Endpoint", + "webhookPush": "Endpoint-lər", + "webhookPushDescription": "Tapşırıqlar tamamlandıqda və ya uğursuz olduqda bu endpoint-lərə HTTP POST bildirişləri göndər", + "scriptExecution": "Skriptlər", + "scriptExecutionDescription": "Endirmə uğurla tamamlandıqdan sonra bu skriptləri işə sal", + "addWebhook": "Webhook Əlavə Et", + "editWebhook": "Webhook Redaktə Et", + "addScript": "Skript Əlavə Et", + "editScript": "Skript Redaktə Et", + "openDirectory": "Qovluğu Aç", + "browseFiles": "Faylları Gözdən Keçir", + "updateEveryDay": "Gündəlik yenilə", + "updateVersion": "Yenilə", + "checkForUpdates": "Yeniləmələri Yoxla", + "updating": "Yenilənir…", + "colorGreen": "Yaşıl", + "colorRed": "Qırmızı", + "colorRose": "Çəhrayı", + "colorOrange": "Narıncı", + "colorCyan": "Firuzəyi", + "colorBlue": "Mavi", + "colorAmber": "Kəhrəba", + "colorPurple": "Bənövşəyi", + "noExtensions": "Uzantı yoxdur", + "searchExtensions": "Uzantıları axtar…", + "extensionDeveloperGuide": "Uzantı tərtibatçı bələdçisi", + "extensionInstallFromUrl": "URL-dən uzantı quraşdır", + "unableOpenExtensionDocs": "Uzantı tərtibatçı bələdçisini açmaq mümkün olmadı", + "removeExtensionTitle": "Uzantı sistemdən silinsin?", + "removeExtensionConfirm": "“{name}” uzantısını sistemdən silmək istədiyinizə əminsiniz?", + "uninstall": "Sistemdən sil", + "unableLoadExtensions": "Uzantıları yükləmək mümkün olmadı", + "filters": "Süzgəclər", + "allTasks": "Bütün Tapşırıqlar", + "searchDownloads": "Endirmələri axtar…", + "searchHistory": "Tarixçədə axtar…", + "addTask": "Tapşırıq Əlavə Et", + "emptyTaskList": "Bu siyahıda tapşırıq yoxdur", + "noMatchingTasks": "Uyğun tapşırıq tapılmadı", + "taskList": "Tapşırıqlar", + "selectedCount": "{selected} / {total} seçilib", + "unableOpenPath": "{path} açmaq mümkün olmadı", + "unableLocatePath": "{path} tapmaq mümkün olmadı", + "clearSelection": "Seçimi təmizlə", + "deselectTask": "Seçimi ləğv et", + "selectTask": "Seç", + "updateManually": "Əl ilə yenilə", + "stopListening": "Dinləməni dayandır", + "listenForUrl": "URL üçün dinlə", + "waitingForReplacementUrl": "Əvəzedici URL gözlənilir", + "taskNotFound": "Tapşırıq tapılmadı", + "storagePath": "Yaddaş Yolu", + "directory": "Qovluq", + "mode": "Rejim", + "keepOriginalName": "Orijinal adı saxla", + "enterCount": "Sayı daxil edin", + "chooseDownloadDirectory": "Endirmə qovluğunu seçin", + "applicationStorage": "Tətbiq yaddaşı", + "applicationStorageDescription": "Etibarlı tətbiq yaddaşı; tətbiq silindikdə fayllar silinir", + "publicDownloadsDescription": "Silindikdən sonra faylları saxlayır; əlçatanlıq Android cihazından asılıdır", + "downloadsDirectoryUnavailable": "Gopeed bu cihazda Download/Gopeed qovluğuna yaza bilmir", + "enterValue": "Dəyəri daxil edin", + "trackers": "Tracker-lər", + "oneTrackerPerLine": "Hər sətirdə bir tracker URL-i", + "pasteDownloadLinks": "HTTP, HTTPS və ya MAGNET keçidlərini yapışdırın, ya da bura torrent faylı buraxın", + "unableReadTorrent": "Torrent faylı oxuna bilmədi: {error}", + "torrentOnly": "Yalnız .torrent faylları dəstəklənir", + "torrentDataEmpty": "Torrent fayl məlumatı boşdur", + "torrentPathUnavailable": "Torrent fayl yoluna giriş mümkün olmadı", + "selectAtLeastOneFile": "Ən azı bir fayl seçin", + "resolvedFiles": "Tapılmış Fayllar", + "resolvedFileCount": "{count} fayl tapıldı", + "resourceHasNoFiles": "Bu resursda seçilə bilən fayl siyahısı yoxdur", + "noFiles": "Fayl yoxdur", + "historyLinks": "Tarixçə keçidləri", + "noHistory": "Tarixçə yoxdur", + "directDownloadDescription": "Təhlili keç və tapşırıqları dərhal yarat", + "taskProgress": "Tapşırığın irəliləyişi", + "updateAndResume": "Yenilə və davam et", + "httpHeaders": "HTTP başlıqları", + "piece": "Parça {number}", + "pieceRange": "Parçalar {start}-{end}", + "connectionStatus": "Əlaqə Vəziyyəti", + "connectionCount": "{count} əlaqə", + "retrying": "Yenidən cəhd edilir", + "connectionFailed": "Əlaqə uğursuz oldu", + "connectionFailedRetries": "{count} cəhddən sonra əlaqə uğursuz oldu", + "retryingCount": "Yenidən cəhd edilir · {count}-ci cəhd", + "pieceStatus": "Parça Vəziyyəti", + "peers": "Peer-lər", + "seeders": "Seed edənlər", + "leechers": "Endirənlər", + "downloadedAmount": "Endirilib", + "uploadedAmount": "Yüklənib", + "shareRatio": "Paylaşım Nisbəti", + "shareDuration": "Paylaşım Müddəti", + "connectedPeers": "{count} Peer qoşulub", + "active": "Aktiv", + "completedPieces": "Tamamlanmış Parçalar", + "activePeers": "{count} Peer aktivdir", + "incomplete": "Tamamlanmamış", + "address": "Ünvan", + "client": "Müştəri", + "pieces": "Parçalar", + "source": "Mənbə", + "fileRelevance": "Faylın uyğunluğu", + "taskDetails": "Tapşırığın Detalları", + "taskDetailsInfoTab": "Detallar", + "taskDetailsFilesTab": "Fayllar", + "taskDetailsStatsTab": "Statistika", + "noStatistics": "Statistika mövcud deyil", + "noPeers": "Heç bir Peer yoxdur", + "deleteTasksTitle": "{count} tapşırıq silinsin?", + "keepFilesDescription": "Yalnız tapşırıq qeydini Gopeed-dən sil", + "readingRuntimeStatus": "İşləmə vəziyyəti oxunur…", + "apiConfigSaved": "API parametrləri yadda saxlanıldı", + "apiConfigPendingApply": "Yadda saxlanılan parametrlər işləyən xidmətə hələ tətbiq edilməyib", + "apiServerRunning": "İşləyir", + "apiServerStopped": "Dayandırılıb", + "apiServerFailed": "Xəta", + "apiServerRuntimeStatus": "İşləmə vəziyyəti", + "startApiServer": "Başlat", + "stopApiServer": "Dayandır", + "saveAndRestartApiServer": "Yadda saxla və yenidən başlat", + "restartApiServerConfirm": "REST API xidməti işləyir. Bu parametrləri tətbiq etmək onu yenidən başladacaq və mövcud API əlaqələrini kəsəcək.", + "disableApiServerConfirm": "REST API serveri deaktiv edilsin? Mövcud API əlaqələri kəsiləcək.", + "socketPathRequired": "Soket yolu tələb olunur", + "serverRequired": "Server tələb olunur", + "portRangeError": "0 ilə 65535 arasında port daxil edin", + "portInUseValue": "Port {port} artıq istifadədədir", + "backgroundLocationPermissionRequired": "Fon endirmələri üçün 'Həmişə icazə ver' məkan girişi tələb olunur", + "scriptPath": "Skript Yolu", + "logDirectoryUnavailable": "Loq qovluğu işə salınmayıb", + "unableOpenExternalLink": "Xarici keçidi açmaq mümkün olmadı", + "neverUpdated": "Heç vaxt yenilənməyib", + "lastUpdated": "Son yenilənmə: {time}", + "unableLoadSettings": "Parametrləri yükləmək mümkün olmadı", + "fieldRequired": "{field} tələb olunur", + "addGithubMirror": "GitHub Güzgüsü Əlavə Et", + "editGithubMirror": "GitHub Güzgüsünü Redaktə Et", + "confirmDeleteTitle": "Silinmə təsdiqlənsin?", + "confirmDeleteNamed": "“{name}” silmək istədiyinizə əminsiniz?", + "categoryFieldsRequired": "Kateqoriya adı və yolu tələb olunur", + "addCategory": "Kateqoriya Əlavə Et", + "editCategory": "Kateqoriyanı Redaktə Et", + "deleteCategoryTitle": "Kateqoriya silinsin?", + "updateFailed": "Yeniləmə uğursuz oldu. Zəhmət olmasa sonra yenidən cəhd edin", + "newVersionAvailable": "Yeni versiya mövcuddur", + "releaseNotes": "Yeniliklər", + "noReleaseNotes": "Buraxılış qeydləri mövcud deyil", + "preparingDownload": "Endirmə hazırlanır…", + "booleanValueHint": "true / false", + "@runtimeInitializationFailed": { + "placeholders": { + "error": {} + } + }, + "@secondsRemaining": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@minutesRemaining": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@hoursRemaining": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@secondsDuration": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@minutesDuration": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@hoursDuration": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@currentVersion": { + "placeholders": { + "version": {} + } + }, + "@removeExtensionConfirm": { + "placeholders": { + "name": {} + } + }, + "@selectedCount": { + "placeholders": { + "selected": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "@unableOpenPath": { + "placeholders": { + "path": {} + } + }, + "@unableLocatePath": { + "placeholders": { + "path": {} + } + }, + "@unableReadTorrent": { + "placeholders": { + "error": {} + } + }, + "@resolvedFileCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@piece": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "@pieceRange": { + "placeholders": { + "start": { + "type": "int" + }, + "end": { + "type": "int" + } + } + }, + "@connectionCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@connectionFailedRetries": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@retryingCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@connectedPeers": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@activePeers": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@deleteTasksTitle": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@portInUseValue": { + "placeholders": { + "port": { + "type": "int" + } + } + }, + "@lastUpdated": { + "placeholders": { + "time": {} + } + }, + "@fieldRequired": { + "placeholders": { + "field": {} + } + }, + "@confirmDeleteNamed": { + "placeholders": { + "name": {} + } + }, + "androidForegroundServiceChannel": "Gopeed Fon Xidməti", + "androidForegroundNotificationTitle": "Gopeed işləyir", + "androidForegroundNotificationText": "Endirmələr fonda davam edə bilər", + "mcpDescription": "AI agentləri ilə əməkdaşlıq edin, sonsuz imkanları kəşf edin", + "connectAgent": "Quraşdırma bələdçisi", + "connectMcpAgent": "MCP agenti qoş", + "mcpAgentDialogDescription": "İstifadə etdiyiniz agenti seçin, sonra onun əlaqə konfiqurasiyasını kopyalayın.", + "mcpNotRunningHint": "MCP xidməti aktiv deyil. Zəhmət olmasa aktivləşdirin", + "webMcpApiTokenHint": "Əgər API açarı konfiqurasiya etmisinizsə, agenti qoşarkən doğrulama məlumatı tələb olunur", + "useMcpApiTokenTemplate": "Doğrulamalı konfiqurasiyaya keç", + "webApiConfigReadOnlyHelp": "Veb versiyası API parametrlərinin onlayn dəyişdirilməsini dəstəkləmir. Server başlanğıc seçimlərini və ya konfiqurasiya faylını yeniləyin, sonra dəyişikliklərin qüvvəyə minməsi üçün xidməti yenidən başladın.", + "mcpRequiresTcp": "Agent əlaqə təlimatları API protokolu TCP olduqda mövcuddur.", + "emptyTaskCreateHint": "Endirməyə başlamaq üçün {action} düyməsinə klikləyin" +} diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index 7743b429..fe733610 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -18,6 +18,7 @@ "exit": "Surt", "pressBackAgainToExit": "Torna a prémer enrere per sortir", "create": "Crea una tasca", + "createAction": "Crea", "directDownload": "Baixada directa", "advancedOptions": "Opcions avançades", "followSettings": "Segueix la configuració", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "Desa com a carpeta de baixades predeterminada", "downloadDirValid": "Seleccioneu la carpeta de baixades", "connections": "Connexions", + "maxRetries": "Reintents màxims", "useServerCtime": "Utilitza l'hora del servidor per a la creació de fitxers", "maxRunning": "Màxim de tasques en execució", "defaultDirectDownload": "Activa la baixada directa per defecte", @@ -102,6 +104,7 @@ "extensions": "Extensions", "extensionInstallUrl": "URL d'instal·lació", "extensionInstallSuccess": "S'ha instal·lat amb èxit", + "extensionUpdateConfirm": "Vols actualitzar «{name}» a la versió més recent?", "extensionUpdateSuccess": "S'ha actualitzat amb èxit", "extensionDelete": "Suprimeix l'extensió", "extensionAlreadyLatest": "Ja està actualitzada", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count}", "resourceHasNoFiles": "El recurs no té cap llista de fitxers per seleccionar", "noFiles": "Sense fitxers", - "createHistory": "Crea un historial", + "historyLinks": "Enllaços de l'historial", "noHistory": "Sense historial", "directDownloadDescription": "Omet la resolució i crea les tasques immediatament", "taskProgress": "Progrés de la tasca", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "La versió web no permet modificar la configuració de l’API en línia. Actualitza les opcions d’inici del servidor o el fitxer de configuració i reinicia el servei perquè els canvis tinguin efecte.", "webMcpApiTokenHint": "Si heu configurat un token d’API, cal proporcionar la informació d’autenticació en connectar l’agent", "useMcpApiTokenTemplate": "Canvia a la configuració autenticada", - "mcpRequiresTcp": "Les instruccions de connexió d’agents estan disponibles quan el protocol de l’API és TCP." + "mcpRequiresTcp": "Les instruccions de connexió d’agents estan disponibles quan el protocol de l’API és TCP.", + "emptyTaskCreateHint": "Fes clic a {action} per començar a baixar" } diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index 2a347170..058a4407 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -18,6 +18,7 @@ "exit": "Beenden", "pressBackAgainToExit": "Drücken Sie erneut Zurück, um die App zu beenden", "create": "Aufgabe Erstellen", + "createAction": "Erstellen", "directDownload": "Direkter Download", "advancedOptions": "Erweiterte Einstellungen", "followSettings": "Einstellungen Folgen", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "Als Standard-Downloadordner speichern", "downloadDirValid": "Bitte wählen Sie das Download-Verzeichnis", "connections": "Anschlüsse", + "maxRetries": "Maximale Wiederholungsversuche", "useServerCtime": "Serverzeit für die Dateierstellung verwenden", "maxRunning": "Maximale Anzahl ausgeführter Aufgaben", "defaultDirectDownload": "Direkten Download standardmäßig aktivieren", @@ -102,6 +104,7 @@ "extensions": "Erweiterungen", "extensionInstallUrl": "Installations-URL", "extensionInstallSuccess": "Erfolgreich installiert", + "extensionUpdateConfirm": "„{name}“ auf die neueste Version aktualisieren?", "extensionUpdateSuccess": "Erfolgreich aktualisiert", "extensionDelete": "Erweiterung löschen", "extensionAlreadyLatest": "Es ist bereits die neueste Version", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count}-Dateien behoben", "resourceHasNoFiles": "Die Ressource verfügt über keine auswählbare Dateiliste", "noFiles": "Keine Dateien", - "createHistory": "Verlauf erstellen", + "historyLinks": "Verlaufslinks", "noHistory": "Keine Historie", "directDownloadDescription": "Überspringen Sie die Lösung und erstellen Sie sofort Aufgaben", "taskProgress": "Aufgabenfortschritt", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "In der Web-Version können API-Einstellungen nicht online geändert werden. Ändere die Server-Startoptionen oder die Konfigurationsdatei und starte den Dienst neu, damit die Änderungen wirksam werden.", "webMcpApiTokenHint": "Wenn du ein API-Token konfiguriert hast, müssen beim Verbinden des Agents Authentifizierungsinformationen übermittelt werden", "useMcpApiTokenTemplate": "Zur authentifizierten Konfiguration wechseln", - "mcpRequiresTcp": "Anweisungen zur Agent-Verbindung sind verfügbar, wenn das API-Protokoll TCP ist." + "mcpRequiresTcp": "Anweisungen zur Agent-Verbindung sind verfügbar, wenn das API-Protokoll TCP ist.", + "emptyTaskCreateHint": "Klicke auf {action}, um den Download zu starten" } diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index 578e762d..9af2806f 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -18,6 +18,7 @@ "exit": "Exit", "pressBackAgainToExit": "Press back again to exit", "create": "Create Task", + "createAction": "Create", "directDownload": "Direct Download", "advancedOptions": "Advanced Options", "followSettings": "Follow Settings", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "Save as default download directory", "downloadDirValid": "Please select the download directory", "connections": "Connections", + "maxRetries": "Max retries", "useServerCtime": "Use server time for file creation", "maxRunning": "Concurrent Downloads", "defaultDirectDownload": "Direct Download by Default", @@ -109,6 +111,14 @@ "extensions": "Extensions", "extensionInstallUrl": "Install URL", "extensionInstallSuccess": "Installed successfully", + "extensionUpdateConfirm": "Update “{name}” to the latest version?", + "@extensionUpdateConfirm": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "extensionUpdateSuccess": "Updated successfully", "extensionDelete": "Delete Extension", "extensionAlreadyLatest": "It's already the latest version", @@ -239,6 +249,7 @@ "ed2kNodesDatHint": "nodes.dat path or URL, one per line", "desktopNotification": "Desktop Notifications", "backgroundLocationKeepAlive": "Background Downloads", + "backgroundContinuedProcessing": "Continued Processing (iOS 26+)", "notificationTaskDone": "Task completed", "notificationTaskError": "Task error", "appTitle": "Gopeed Download Manager", @@ -281,6 +292,8 @@ "browserExtensionDescription": "Send browser downloads directly to Gopeed after installation", "desktopNotificationDescription": "Show a system notification when a task completes or fails", "backgroundLocationKeepAliveDescription": "Use location access to keep downloads active in the background", + "backgroundContinuedProcessingDescription": "Use iOS continued processing and its system Live Activity to keep downloads running without location access", + "backgroundContinuedProcessingUnsupported": "Continued Processing requires iOS 26 or later", "currentVersion": "Current version {version}", "contributors": "Contributors", "viewContributors": "View contributors", @@ -366,7 +379,7 @@ "resolvedFileCount": "{count, plural, =1{1 file resolved} other{{count} files resolved}}", "resourceHasNoFiles": "The resource has no selectable file list", "noFiles": "No files", - "createHistory": "Create History", + "historyLinks": "History Links", "noHistory": "No history", "directDownloadDescription": "Skip resolving and create tasks immediately", "taskProgress": "Task progress", @@ -625,5 +638,14 @@ "webMcpApiTokenHint": "If you configured an API token, authentication information is required when connecting the agent", "useMcpApiTokenTemplate": "Switch to authenticated configuration", "webApiConfigReadOnlyHelp": "The web version cannot modify API settings online. Update the server startup options or configuration file, then restart the service for changes to take effect.", - "mcpRequiresTcp": "Agent connection instructions are available when the API protocol is TCP." + "mcpRequiresTcp": "Agent connection instructions are available when the API protocol is TCP.", + "emptyTaskCreateHint": "Click {action} to start downloading", + "@emptyTaskCreateHint": { + "description": "Empty-list guidance; action is replaced with an inline create button.", + "placeholders": { + "action": { + "type": "String" + } + } + } } diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index 6aef1468..96a65113 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -18,6 +18,7 @@ "exit": "Salir", "pressBackAgainToExit": "Pulsa Atrás de nuevo para salir", "create": "Crear Tarea", + "createAction": "Crear", "directDownload": "Descarga Directa", "advancedOptions": "Opciones Avanzadas", "followSettings": "Siga las opciones", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "Guardar como carpeta de descargas predeterminada", "downloadDirValid": "Por favor, selecciona el directorio de descarga", "connections": "Conexiones", + "maxRetries": "Reintentos máximos", "useServerCtime": "Usar tiempo del servidor para la creación de archivos", "maxRunning": "Máximo de Tareas en Ejecución", "defaultDirectDownload": "Ver descarga directa por defecto", @@ -102,6 +104,7 @@ "extensions": "Extensiones", "extensionInstallUrl": "URL de instalación", "extensionInstallSuccess": "Instalado con éxito", + "extensionUpdateConfirm": "¿Actualizar «{name}» a la última versión?", "extensionUpdateSuccess": "Actualizado con éxito", "extensionDelete": "Eliminar Extensión", "extensionAlreadyLatest": "Ya es la versión más reciente", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count} resueltos", "resourceHasNoFiles": "El recurso no tiene lista de archivos seleccionable", "noFiles": "No hay archivos", - "createHistory": "Crear historia", + "historyLinks": "Enlaces del historial", "noHistory": "No hay historia", "directDownloadDescription": "Saltar a resolver y crear tareas inmediatamente", "taskProgress": "Progresos en la tarea", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "La versión web no permite modificar la configuración de la API en línea. Actualiza las opciones de inicio del servidor o el archivo de configuración y reinicia el servicio para aplicar los cambios.", "webMcpApiTokenHint": "Si has configurado un token de API, debes proporcionar la información de autenticación al conectar el agente", "useMcpApiTokenTemplate": "Cambiar a la configuración autenticada", - "mcpRequiresTcp": "Las instrucciones de conexión de agentes están disponibles cuando el protocolo de la API es TCP." + "mcpRequiresTcp": "Las instrucciones de conexión de agentes están disponibles cuando el protocolo de la API es TCP.", + "emptyTaskCreateHint": "Haz clic en {action} para empezar a descargar" } diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index 7398b5c3..a7cd2144 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -18,6 +18,7 @@ "exit": "خروج", "pressBackAgainToExit": "برای خروج دوباره دکمه بازگشت را فشار دهید", "create": "ایجاد کار", + "createAction": "ایجاد", "directDownload": "دانلود مستقیم", "advancedOptions": "گزینه های پیشرفته", "followSettings": "تنظیمات را دنبال کنید", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "ذخیره به‌عنوان پوشه پیش‌فرض دانلود", "downloadDirValid": "لطفا دایرکتوری دانلود را انتخاب کنید", "connections": "اتصالات", + "maxRetries": "حداکثر تلاش مجدد", "useServerCtime": "استفاده از زمان سرور برای ایجاد فایل", "maxRunning": "حداکثر وظایف در حال اجرا", "defaultDirectDownload": "دانلود مستقیم را به صورت پیش فرض بررسی کنید", @@ -102,6 +104,7 @@ "extensions": "افزونه‌ها", "extensionInstallUrl": "نشانی نصب", "extensionInstallSuccess": "نصب شده با موفقیت", + "extensionUpdateConfirm": "«{name}» به آخرین نسخه به‌روزرسانی شود؟", "extensionUpdateSuccess": "به روز رسانی موفقیت آمیز", "extensionDelete": "حذف افزونه", "extensionAlreadyLatest": "این نسخه در حال حاضر آخرین نسخه است", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count} فایل شناسایی شد", "resourceHasNoFiles": "منبع هیچ لیست فایل قابل انتخاب ندارد", "noFiles": "فایل ها", - "createHistory": "ایجاد تاریخ", + "historyLinks": "پیوندهای تاریخچه", "noHistory": "تاریخ", "directDownloadDescription": "پیاده سازی و ایجاد وظایف بلافاصله", "taskProgress": "پیشرفت شغلی", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "نسخه وب از ویرایش آنلاین تنظیمات API پشتیبانی نمی‌کند. گزینه‌های راه‌اندازی سرور یا فایل پیکربندی را تغییر دهید و برای اعمال تغییرات سرویس را دوباره راه‌اندازی کنید.", "webMcpApiTokenHint": "اگر توکن API را پیکربندی کرده‌اید، هنگام اتصال عامل باید اطلاعات احراز هویت را ارسال کنید", "useMcpApiTokenTemplate": "تغییر به پیکربندی احراز هویت", - "mcpRequiresTcp": "راهنمای اتصال عامل زمانی در دسترس است که پروتکل API روی TCP باشد." + "mcpRequiresTcp": "راهنمای اتصال عامل زمانی در دسترس است که پروتکل API روی TCP باشد.", + "emptyTaskCreateHint": "برای شروع دانلود روی {action} کلیک کنید" } diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index 43404fdc..9f269b01 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -18,6 +18,7 @@ "exit": "Quitter", "pressBackAgainToExit": "Appuyez à nouveau sur Retour pour quitter", "create": "Créer une tâche", + "createAction": "Créer", "directDownload": "Téléchargement direct", "advancedOptions": "Options avancées", "followSettings": "Suivre les paramètres", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "Enregistrer comme dossier de téléchargement par défaut", "downloadDirValid": "Veuillez sélectionner le répertoire de téléchargement", "connections": "Connexions", + "maxRetries": "Nombre maximal de tentatives", "useServerCtime": "Utiliser l'heure du serveur pour la création de fichier", "maxRunning": "Tâches en cours maximum", "defaultDirectDownload": "Vérifiez le téléchargement direct par défaut", @@ -102,6 +104,7 @@ "extensions": "Extensions", "extensionInstallUrl": "URL d'installation", "extensionInstallSuccess": "Installé avec succès", + "extensionUpdateConfirm": "Mettre à jour « {name} » vers la dernière version ?", "extensionUpdateSuccess": "Mis à jour avec succès", "extensionDelete": "Supprimer l'extension", "extensionAlreadyLatest": "C'est déjà la dernière version", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count} résolus", "resourceHasNoFiles": "La ressource n'a pas de liste de fichiers sélectionnable", "noFiles": "Aucun fichier", - "createHistory": "Créer l'historique", + "historyLinks": "Liens de l'historique", "noHistory": "Pas d'histoire", "directDownloadDescription": "Sautez la résolution et créez immédiatement des tâches", "taskProgress": "Progrès accomplis dans la tâche", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "La version web ne permet pas de modifier les paramètres de l’API en ligne. Modifiez les options de démarrage du serveur ou le fichier de configuration, puis redémarrez le service pour appliquer les changements.", "webMcpApiTokenHint": "Si vous avez configuré un jeton API, les informations d’authentification doivent être transmises lors de la connexion de l’agent", "useMcpApiTokenTemplate": "Passer à la configuration authentifiée", - "mcpRequiresTcp": "Les instructions de connexion des agents sont disponibles lorsque le protocole de l’API est TCP." + "mcpRequiresTcp": "Les instructions de connexion des agents sont disponibles lorsque le protocole de l’API est TCP.", + "emptyTaskCreateHint": "Cliquez sur {action} pour commencer le téléchargement" } diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index cd5d2b2d..db44d5a7 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -18,6 +18,7 @@ "exit": "Kilépés", "pressBackAgainToExit": "Nyomja meg újra a Vissza gombot a kilépéshez", "create": "Feladat létrehozása", + "createAction": "Létrehozás", "directDownload": "Közvetlen letöltés", "advancedOptions": "Haladó beállítások", "followSettings": "Beállítások követése", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "Mentés alapértelmezett letöltési mappaként", "downloadDirValid": "Kérjük, válassza ki a letöltési könyvtárat", "connections": "Kapcsolatok", + "maxRetries": "Maximális újrapróbálkozások", "useServerCtime": "Használja a szerveridőt a fájl létrehozásához", "maxRunning": "Maximális futó feladatok", "defaultDirectDownload": "Alapértelmezettként ellenőrizze a közvetlen letöltést", @@ -102,6 +104,7 @@ "extensions": "Bővítmények", "extensionInstallUrl": "Telepítési URL", "extensionInstallSuccess": "Sikeresen telepítve", + "extensionUpdateConfirm": "Frissíti a(z) „{name}” bővítményt a legújabb verzióra?", "extensionUpdateSuccess": "Sikeresen frissítve", "extensionDelete": "Bővítmény törlése", "extensionAlreadyLatest": "Ez már a legfrissebb verzió", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count} fájlok megoldva", "resourceHasNoFiles": "Az erőforrásnak nincs kiválasztható fájllistája", "noFiles": "Nincsenek fájlok", - "createHistory": "Készítsen előzményeket", + "historyLinks": "Előzményhivatkozások", "noHistory": "Nincs előzmény", "directDownloadDescription": "Hagyja ki a megoldást, és azonnal hozzon létre feladatokat", "taskProgress": "A feladat előrehaladása", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "A webes verzióban az API-beállítások nem módosíthatók online. Módosítsd a kiszolgáló indítási beállításait vagy a konfigurációs fájlt, majd indítsd újra a szolgáltatást.", "webMcpApiTokenHint": "Ha API-tokent állítottál be, az ügynök csatlakoztatásakor meg kell adni a hitelesítési adatokat", "useMcpApiTokenTemplate": "Váltás hitelesített konfigurációra", - "mcpRequiresTcp": "Az ügynökcsatlakoztatási útmutató akkor érhető el, ha az API protokollja TCP." + "mcpRequiresTcp": "Az ügynökcsatlakoztatási útmutató akkor érhető el, ha az API protokollja TCP.", + "emptyTaskCreateHint": "Kattints a {action} gombra a letöltés indításához" } diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index 1f87a970..87a36a65 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -18,6 +18,7 @@ "exit": "Keluar", "pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar", "create": "Membuat Tugas", + "createAction": "Buat", "directDownload": "Unduhan Langsung", "advancedOptions": "Pengaturan Lanjutan", "followSettings": "Mengikuti Pengaturan", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "Simpan sebagai folder unduhan default", "downloadDirValid": "Silahkan pilih direktori unduhan", "connections": "Koneksi", + "maxRetries": "Percobaan ulang maks.", "useServerCtime": "Gunakan waktu server untuk pembuatan file", "maxRunning": "Tugas Maksimum yang Berjalan", "defaultDirectDownload": "Pilih unduhan langsung sebagai opsi standar", @@ -102,6 +104,7 @@ "extensions": "Ekstensi", "extensionInstallUrl": "URL Instal", "extensionInstallSuccess": "Diinstal dengan sukses", + "extensionUpdateConfirm": "Perbarui “{name}” ke versi terbaru?", "extensionUpdateSuccess": "Diperbaruhi dengan sukses", "extensionDelete": "Hapus Ekstensi", "extensionAlreadyLatest": "Ini sudah versi terbaru", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count} terselesaikan", "resourceHasNoFiles": "Sumber daya tidak memiliki daftar berkas yang dapat dipilih", "noFiles": "Tidak ada berkas", - "createHistory": "Buat Riwayat", + "historyLinks": "Tautan Riwayat", "noHistory": "Tidak ada sejarah", "directDownloadDescription": "Lewatkan pemecahan dan buat tugas segera", "taskProgress": "Kemajuan tugas", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "Versi web tidak dapat mengubah pengaturan API secara online. Perbarui opsi startup server atau file konfigurasi, lalu mulai ulang layanan agar perubahan diterapkan.", "webMcpApiTokenHint": "Jika Anda telah mengatur token API, informasi autentikasi harus disertakan saat menghubungkan agen", "useMcpApiTokenTemplate": "Beralih ke konfigurasi terautentikasi", - "mcpRequiresTcp": "Petunjuk koneksi agen tersedia saat protokol API menggunakan TCP." + "mcpRequiresTcp": "Petunjuk koneksi agen tersedia saat protokol API menggunakan TCP.", + "emptyTaskCreateHint": "Klik {action} untuk mulai mengunduh" } diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index 7d298adb..b04fd493 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -18,6 +18,7 @@ "exit": "Uscita", "pressBackAgainToExit": "Premi di nuovo Indietro per uscire", "create": "Crea attività", + "createAction": "Crea", "directDownload": "Scaricamento diretto", "advancedOptions": "Opzioni avanzate", "followSettings": "Seguire le impostazioni", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "Salva come cartella di download predefinita", "downloadDirValid": "Seleziona la directory di scaricamento", "connections": "Connessioni", + "maxRetries": "Tentativi massimi", "useServerCtime": "Utilizza l'ora del server per la creazione dei file", "maxRunning": "Numero massimo di attività in esecuzione", "defaultDirectDownload": "Controlla il download diretto per impostazione predefinita", @@ -102,6 +104,7 @@ "extensions": "Estensioni", "extensionInstallUrl": "Installa URL", "extensionInstallSuccess": "Installato con successo", + "extensionUpdateConfirm": "Aggiornare «{name}» alla versione più recente?", "extensionUpdateSuccess": "Aggiornato con successo", "extensionDelete": "Elimina estensione", "extensionAlreadyLatest": "È già l'ultima versione", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count} risolti", "resourceHasNoFiles": "La risorsa non ha un elenco di file selezionabile", "noFiles": "Nessun file", - "createHistory": "Creare la storia", + "historyLinks": "Link della cronologia", "noHistory": "Nessuna storia", "directDownloadDescription": "Salta la risoluzione e crea immediatamente i compiti", "taskProgress": "Prospettive di attività", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "La versione web non consente di modificare online le impostazioni API. Aggiorna le opzioni di avvio del server o il file di configurazione, quindi riavvia il servizio per applicare le modifiche.", "webMcpApiTokenHint": "Se hai configurato un token API, devi fornire le informazioni di autenticazione quando colleghi l’agente", "useMcpApiTokenTemplate": "Passa alla configurazione autenticata", - "mcpRequiresTcp": "Le istruzioni per connettere gli agenti sono disponibili quando il protocollo API è TCP." + "mcpRequiresTcp": "Le istruzioni per connettere gli agenti sono disponibili quando il protocollo API è TCP.", + "emptyTaskCreateHint": "Fai clic su {action} per iniziare il download" } diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index 0bdbc743..009a7bea 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -18,6 +18,7 @@ "exit": "終了", "pressBackAgainToExit": "もう一度戻るボタンを押すと終了します", "create": "タスクを作成", + "createAction": "作成", "directDownload": "直接ダウンロード", "advancedOptions": "詳細オプション", "followSettings": "設定に従う", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "デフォルトのダウンロード先として保存", "downloadDirValid": "ダウンロードディレクトリを選択してください", "connections": "接続", + "maxRetries": "最大リトライ回数", "useServerCtime": "ファイル作成時にサーバー時間を使用する", "maxRunning": "最大実行タスク", "defaultDirectDownload": "デフォルトで直接ダウンロードにチェックを入れる", @@ -102,6 +104,7 @@ "extensions": "拡張機能", "extensionInstallUrl": "インストールURL", "extensionInstallSuccess": "正常にインストールされました", + "extensionUpdateConfirm": "「{name}」を最新バージョンに更新しますか?", "extensionUpdateSuccess": "正常に更新されました", "extensionDelete": "拡張機能を削除", "extensionAlreadyLatest": "既に最新バージョンです", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count} ファイルが解決しました", "resourceHasNoFiles": "リソースには、選択可能なファイルリストはありません", "noFiles": "ファイルがない場合", - "createHistory": "歴史を創造する", + "historyLinks": "履歴リンク", "noHistory": "歴史なし", "directDownloadDescription": "解決をスキップし、すぐにタスクを作成", "taskProgress": "タスクの進捗", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "Web 版では API 設定をオンラインで変更できません。サーバーの起動オプションまたは設定ファイルを変更し、サービスを再起動して反映してください。", "webMcpApiTokenHint": "API トークンを設定している場合、エージェント接続時に認証情報を渡す必要があります", "useMcpApiTokenTemplate": "認証付き設定に切り替える", - "mcpRequiresTcp": "API プロトコルが TCP の場合にエージェント接続手順を利用できます。" + "mcpRequiresTcp": "API プロトコルが TCP の場合にエージェント接続手順を利用できます。", + "emptyTaskCreateHint": "{action}をクリックしてダウンロードを開始" } diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index db8caf2d..8f89366e 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -18,6 +18,7 @@ "exit": "종료", "pressBackAgainToExit": "종료하려면 뒤로 버튼을 한 번 더 누르세요", "create": "작업 생성", + "createAction": "생성", "directDownload": "바로 다운로드", "advancedOptions": "고급 옵션", "followSettings": "설정값 사용", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "기본 다운로드 폴더로 저장", "downloadDirValid": "다운로드 폴더를 선택해 주세요", "connections": "연결 수", + "maxRetries": "최대 재시도 횟수", "useServerCtime": "파일 생성 시간에 서버 시간 사용", "maxRunning": "최대 동시 작업 수", "defaultDirectDownload": "기본으로 바로 다운로드 사용", @@ -102,6 +104,7 @@ "extensions": "확장 프로그램", "extensionInstallUrl": "설치 URL", "extensionInstallSuccess": "설치되었습니다", + "extensionUpdateConfirm": "“{name}”을(를) 최신 버전으로 업데이트할까요?", "extensionUpdateSuccess": "업데이트되었습니다", "extensionDelete": "확장 프로그램 삭제", "extensionAlreadyLatest": "이미 최신 버전입니다", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count} 파일 해결", "resourceHasNoFiles": "리소스에는 선택 가능한 파일 목록이 없습니다", "noFiles": "파일 없음", - "createHistory": "회사연혁", + "historyLinks": "기록 링크", "noHistory": "의 역사", "directDownloadDescription": "재해를 드리며 즉시 업무를 만듭니다", "taskProgress": "작업 진행", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "웹 버전에서는 API 설정을 온라인으로 수정할 수 없습니다. 서버 시작 옵션이나 구성 파일을 변경한 후 서비스를 다시 시작해야 적용됩니다.", "webMcpApiTokenHint": "API 토큰을 설정한 경우 에이전트 연결 시 인증 정보를 전달해야 합니다", "useMcpApiTokenTemplate": "인증 구성으로 전환", - "mcpRequiresTcp": "API 프로토콜이 TCP일 때 에이전트 연결 안내를 사용할 수 있습니다." + "mcpRequiresTcp": "API 프로토콜이 TCP일 때 에이전트 연결 안내를 사용할 수 있습니다.", + "emptyTaskCreateHint": "{action}을 눌러 다운로드 시작" } diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index c255d506..a6b9b031 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -18,6 +18,7 @@ "exit": "Wyjście", "pressBackAgainToExit": "Naciśnij ponownie Wstecz, aby wyjść", "create": "Utwórz zadanie", + "createAction": "Utwórz", "directDownload": "Pobieranie bezpośrednie", "advancedOptions": "Zaawansowane", "followSettings": "Podążaj za ustawieniami", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "Zapisz jako domyślny folder pobierania", "downloadDirValid": "Wskaż folder pobierania", "connections": "Połączenia", + "maxRetries": "Maks. liczba ponownych prób", "useServerCtime": "Użyj czasu serwera do utworzenia pliku", "maxRunning": "Maksymalna liczba zadań", "defaultDirectDownload": "Domyślnie sprawdź bezpośredni pobór", @@ -102,6 +104,7 @@ "extensions": "Wtyczki", "extensionInstallUrl": "Instaluj z url", "extensionInstallSuccess": "Instalacja zakończona", + "extensionUpdateConfirm": "Zaktualizować „{name}” do najnowszej wersji?", "extensionUpdateSuccess": "Aktualizacja zakończona", "extensionDelete": "Usuń wtyczkę", "extensionAlreadyLatest": "Wtyczka w aktualnej wersji", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count} plików rozwiązanych", "resourceHasNoFiles": "Zasób nie ma listy plików do wyboru", "noFiles": "Brak plików", - "createHistory": "Utwórz historię", + "historyLinks": "Linki historii", "noHistory": "Brak historii", "directDownloadDescription": "Pomiń rozwiązywanie i tworzyć zadania natychmiast", "taskProgress": "Postępy w realizacji zadań", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "Wersja webowa nie obsługuje edycji ustawień API online. Zmień opcje uruchamiania serwera lub plik konfiguracyjny, a następnie uruchom usługę ponownie.", "webMcpApiTokenHint": "Jeśli skonfigurowano token API, podczas łączenia agenta należy przekazać dane uwierzytelniające", "useMcpApiTokenTemplate": "Przełącz na konfigurację z uwierzytelnianiem", - "mcpRequiresTcp": "Instrukcje łączenia agentów są dostępne, gdy protokół API to TCP." + "mcpRequiresTcp": "Instrukcje łączenia agentów są dostępne, gdy protokół API to TCP.", + "emptyTaskCreateHint": "Kliknij {action}, aby rozpocząć pobieranie" } diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index 8447f830..0fb06371 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -18,6 +18,7 @@ "exit": "Sair", "pressBackAgainToExit": "Prima novamente Voltar para sair", "create": "Criar Tarefa", + "createAction": "Criar", "directDownload": "Download Direto", "advancedOptions": "Opções Avançadas", "followSettings": "Seguir as Configurações", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "Salvar como pasta de download padrão", "downloadDirValid": "Por favor, selecione a pasta", "connections": "Conexões", + "maxRetries": "Tentativas máximas", "useServerCtime": "Usar o tempo de servidor para a criação de ficheiros", "maxRunning": "Máximo de Tarefas Simultâneas", "defaultDirectDownload": "Marcar download direto por padrão", @@ -102,6 +104,7 @@ "extensions": "Extensões", "extensionInstallUrl": "Link de Instalação", "extensionInstallSuccess": "Instalado com Sucesso", + "extensionUpdateConfirm": "Atualizar “{name}” para a versão mais recente?", "extensionUpdateSuccess": "Atualizado com Sucesso", "extensionDelete": "Excluir Extensão", "extensionAlreadyLatest": "Já é a última versão", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count} ficheiros resolvidos", "resourceHasNoFiles": "O recurso não tem lista de arquivos selecionáveis", "noFiles": "Sem arquivos", - "createHistory": "Criar Histórico", + "historyLinks": "Links do histórico", "noHistory": "Sem histórico", "directDownloadDescription": "Ignorar resolver e criar tarefas imediatamente", "taskProgress": "Progresso da tarefa", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "A versão web não permite alterar as definições da API online. Atualize as opções de arranque do servidor ou o ficheiro de configuração e reinicie o serviço para aplicar as alterações.", "webMcpApiTokenHint": "Se configurou um token de API, é necessário fornecer as informações de autenticação ao ligar o agente", "useMcpApiTokenTemplate": "Mudar para a configuração autenticada", - "mcpRequiresTcp": "As instruções de conexão de agentes estão disponíveis quando o protocolo da API é TCP." + "mcpRequiresTcp": "As instruções de conexão de agentes estão disponíveis quando o protocolo da API é TCP.", + "emptyTaskCreateHint": "Clique em {action} para começar a baixar" } diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index 1cc5fe24..b8ebe8e9 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -18,6 +18,7 @@ "exit": "Выход", "pressBackAgainToExit": "Нажмите «Назад» ещё раз, чтобы выйти", "create": "Создать задачу", + "createAction": "Создать", "directDownload": "Прямая загрузка", "advancedOptions": "Расширенные опции", "followSettings": "По умолчанию", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "Сохранить как папку загрузок по умолчанию", "downloadDirValid": "Пожалуйста, выберите папку для загрузки", "connections": "Соединений", + "maxRetries": "Макс. число повторов", "useServerCtime": "Использовать время сервера для создания файла", "maxRunning": "Максимальное количество активных задач", "defaultDirectDownload": "Прямая загрузка по умолчанию", @@ -102,6 +104,7 @@ "extensions": "Расширения", "extensionInstallUrl": "URL для установки", "extensionInstallSuccess": "Установка завершена", + "extensionUpdateConfirm": "Обновить «{name}» до последней версии?", "extensionUpdateSuccess": "Обновление завершено", "extensionDelete": "Удалить расширение", "extensionAlreadyLatest": "Это последняя версия", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count} файлов, разрешенных", "resourceHasNoFiles": "Ресурс не имеет выбираемого списка файлов", "noFiles": "Нет файлов", - "createHistory": "Создаем историю", + "historyLinks": "Ссылки в истории", "noHistory": "Нет истории", "directDownloadDescription": "Немедленно пропустите решение и создайте задачи", "taskProgress": "Прогресс в решении задач", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "В веб-версии нельзя изменить настройки API онлайн. Измените параметры запуска сервера или файл конфигурации, затем перезапустите службу.", "webMcpApiTokenHint": "Если настроен API-токен, при подключении агента необходимо передать данные аутентификации", "useMcpApiTokenTemplate": "Переключиться на конфигурацию с аутентификацией", - "mcpRequiresTcp": "Инструкции по подключению агентов доступны, когда протокол API — TCP." + "mcpRequiresTcp": "Инструкции по подключению агентов доступны, когда протокол API — TCP.", + "emptyTaskCreateHint": "Нажмите {action}, чтобы начать загрузку" } diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index 82870d83..97e6b3c9 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -18,6 +18,7 @@ "exit": "வெளியேறு", "pressBackAgainToExit": "வெளியேற மீண்டும் பின்செல் பொத்தானை அழுத்தவும்", "create": "பணியை உருவாக்கு", + "createAction": "உருவாக்கு", "directDownload": "நேரடி பதிவிறக்கம்", "advancedOptions": "மேம்பட்ட விருப்பங்கள்", "followSettings": "அமைப்புகளைப் பின்தொடரவும்", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "இயல்புநிலை பதிவிறக்க கோப்புறையாக சேமி", "downloadDirValid": "பதிவிரக்குமிடத்தை தேர்வு செயுங்கள்", "connections": "தொடர்புகள்", + "maxRetries": "அதிகபட்ச மறுமுயற்சிகள்", "useServerCtime": "கோப்பு உருவாக்க சேவையக நேரத்தைப் பயன்படுத்தவும்", "maxRunning": "அதிகபட்ச இயங்கும் பணிகள்", "defaultDirectDownload": "இயல்புநிலையாக நேரடிப் பதிவிறக்கத்தைச் சரிபார்க்கவும்", @@ -102,6 +104,7 @@ "extensions": "நீட்டிப்புகள்", "extensionInstallUrl": "நிறுவப்படும் URL", "extensionInstallSuccess": "வெற்றிகரமாக நிறுவப்பட்டது", + "extensionUpdateConfirm": "“{name}” ஐ சமீபத்திய பதிப்பிற்குப் புதுப்பிக்கவா?", "extensionUpdateSuccess": "வெற்றிகரமாக புதுப்பிக்கப்பட்டது", "extensionDelete": "நீட்டிப்பை நீக்கு", "extensionAlreadyLatest": "இது ஏற்கனவே சமீபத்திய பதிப்பாகும்", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count} கோப்புகள் தீர்க்கப்பட்டன", "resourceHasNoFiles": "வளத்தில் தேர்ந்தெடுக்கக்கூடிய கோப்பு பட்டியல் இல்லை", "noFiles": "(% 1 மொத்தம்) Items (Folders, Files), Size", - "createHistory": "வரலாற்றை உருவாக்கு", + "historyLinks": "வரலாற்று இணைப்புகள்", "noHistory": "& வரலாற்றில் கண்டுபிடி... Unlimited (number of lines)", "directDownloadDescription": "தீர்க்கப்படுவதைத் தவிர்த்து, உடனடியாகப் பணிகளை உருவாக்கவும்", "taskProgress": "பணி முன்னேற்றம்", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "இணையப் பதிப்பில் API அமைப்புகளை ஆன்லைனில் மாற்ற முடியாது. சேவையக தொடக்க விருப்பங்கள் அல்லது உள்ளமைவு கோப்பை மாற்றி, மாற்றங்கள் செயல்பட சேவையை மறுதொடக்கம் செய்யவும்.", "webMcpApiTokenHint": "API டோக்கனை அமைத்திருந்தால், முகவரை இணைக்கும்போது அங்கீகாரத் தகவலை அனுப்ப வேண்டும்", "useMcpApiTokenTemplate": "அங்கீகார உள்ளமைவுக்கு மாறு", - "mcpRequiresTcp": "API நெறிமுறை TCP ஆக இருக்கும்போது முகவர் இணைப்பு வழிமுறைகள் கிடைக்கும்." + "mcpRequiresTcp": "API நெறிமுறை TCP ஆக இருக்கும்போது முகவர் இணைப்பு வழிமுறைகள் கிடைக்கும்.", + "emptyTaskCreateHint": "பதிவிறக்கத்தைத் தொடங்க {action} ஐ அழுத்தவும்" } diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index ca979cae..127d7a0c 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -18,6 +18,7 @@ "exit": "Çıkış", "pressBackAgainToExit": "Çıkmak için tekrar geri tuşuna basın", "create": "İşlem Oluştur", + "createAction": "Oluştur", "directDownload": "Direkt İndir", "advancedOptions": "Gelişmiş Seçenekler", "followSettings": "Ayarları Takip Et", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "Varsayılan indirme klasörü olarak kaydet", "downloadDirValid": "Lütfen indirme dizinini seçin", "connections": "Bağlantılar", + "maxRetries": "Maksimum yeniden deneme", "useServerCtime": "Dosya oluşturma tarihi için sunucu zamanını kullan", "maxRunning": "Maksimum Çalışan İşlem", "defaultDirectDownload": "Varsayılan olarak direkt indirmeyi işaretle", @@ -102,6 +104,7 @@ "extensions": "Eklentiler", "extensionInstallUrl": "Kurulum URL'si", "extensionInstallSuccess": "Başarıyla kuruldu", + "extensionUpdateConfirm": "“{name}” en son sürüme güncellensin mi?", "extensionUpdateSuccess": "Başarıyla güncellendi", "extensionDelete": "Eklentiyi Sil", "extensionAlreadyLatest": "Zaten en son sürüm", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count} dosyaları çözüldü", "resourceHasNoFiles": "Kaynak, uygun olmayan dosya listesine sahiptir", "noFiles": "Hiçbir dosya yok", - "createHistory": "Create History Create History", + "historyLinks": "Geçmiş Bağlantıları", "noHistory": "Geçmiş yok", "directDownloadDescription": "Hemen çözümle ve görevleri oluşturun", "taskProgress": "Görev ilerlemesi", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "Web sürümü API ayarlarının çevrimiçi değiştirilmesini desteklemez. Sunucu başlatma seçeneklerini veya yapılandırma dosyasını güncelleyin ve değişikliklerin etkili olması için hizmeti yeniden başlatın.", "webMcpApiTokenHint": "Bir API anahtarı yapılandırdıysanız, ajan bağlanırken kimlik doğrulama bilgileri iletilmelidir", "useMcpApiTokenTemplate": "Kimlik doğrulamalı yapılandırmaya geç", - "mcpRequiresTcp": "Ajan bağlantı yönergeleri API protokolü TCP olduğunda kullanılabilir." + "mcpRequiresTcp": "Ajan bağlantı yönergeleri API protokolü TCP olduğunda kullanılabilir.", + "emptyTaskCreateHint": "İndirmeye başlamak için {action} düğmesine tıklayın" } diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index ceae4052..24e20393 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -18,6 +18,7 @@ "exit": "Вихід", "pressBackAgainToExit": "Натисніть «Назад» ще раз, щоб вийти", "create": "Створити завдання", + "createAction": "Створити", "directDownload": "Пряме завантаження", "advancedOptions": "Додаткові параметри", "followSettings": "За замовчуванням", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "Зберегти як типову папку завантажень", "downloadDirValid": "Будь ласка, виберіть каталог завантажень", "connections": "З’єднання", + "maxRetries": "Макс. кількість повторів", "useServerCtime": "Використовувати час сервера для створення файлу", "maxRunning": "Макс. завдань у черзі", "defaultDirectDownload": "Пряме завантаження за замовчуванням", @@ -102,6 +104,7 @@ "extensions": "Розширення", "extensionInstallUrl": "URL для встановлення", "extensionInstallSuccess": "Встановлено успішно", + "extensionUpdateConfirm": "Оновити «{name}» до останньої версії?", "extensionUpdateSuccess": "Оновлено успішно", "extensionDelete": "Видалити розширення", "extensionAlreadyLatest": "Вже остання версія", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count} файли вирішені", "resourceHasNoFiles": "Ресурс не має вибраного списку файлів", "noFiles": "Немає файлів", - "createHistory": "Історія", + "historyLinks": "Посилання історії", "noHistory": "Історія", "directDownloadDescription": "Вирішити і створювати завдання відразу", "taskProgress": "Завдання", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "У вебверсії не можна змінювати налаштування API онлайн. Змініть параметри запуску сервера або файл конфігурації, а потім перезапустіть службу.", "webMcpApiTokenHint": "Якщо налаштовано API-токен, під час підключення агента потрібно передати дані автентифікації", "useMcpApiTokenTemplate": "Перейти до конфігурації з автентифікацією", - "mcpRequiresTcp": "Інструкції з підключення агентів доступні, коли протокол API — TCP." + "mcpRequiresTcp": "Інструкції з підключення агентів доступні, коли протокол API — TCP.", + "emptyTaskCreateHint": "Натисніть {action}, щоб почати завантаження" } diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index 6ed35bd1..aa3e7a9c 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -18,6 +18,7 @@ "exit": "Thoát", "pressBackAgainToExit": "Nhấn quay lại lần nữa để thoát", "create": "Tạo nhiệm vụ", + "createAction": "Tạo", "directDownload": "Tải trực tiếp", "advancedOptions": "Tùy chọn nâng cao", "followSettings": "Theo thiết lập", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "Lưu làm thư mục tải xuống mặc định", "downloadDirValid": "Vui lòng chọn thư mục tải về", "connections": "Kết nối", + "maxRetries": "Số lần thử lại tối đa", "useServerCtime": "Sử dụng thời gian máy chủ cho việc tạo tệp", "maxRunning": "Số nhiệm vụ tối đa", "defaultDirectDownload": "Kiểm tra trực tiếp tải về theo mặc định", @@ -102,6 +104,7 @@ "extensions": "Tiện ích mở rộng", "extensionInstallUrl": "Liên kết cài đặt", "extensionInstallSuccess": "Cài đặt thành công", + "extensionUpdateConfirm": "Cập nhật “{name}” lên phiên bản mới nhất?", "extensionUpdateSuccess": "Cập nhật thành công", "extensionDelete": "Xóa tiện ích mở rộng", "extensionAlreadyLatest": "Đây đã là phiên bản mới nhất", @@ -352,7 +355,7 @@ "resolvedFileCount": "{count} tập tin được giải quyết", "resourceHasNoFiles": "Không thể chọn danh sách tập tin", "noFiles": "Không có tập tin", - "createHistory": "Tạo ra lịch sử", + "historyLinks": "Liên kết lịch sử", "noHistory": "Không có lịch sử", "directDownloadDescription": "Bỏ qua việc giải quyết và tạo tác vụ ngay lập tức", "taskProgress": "Tiến trình tác vụ", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "Phiên bản web không hỗ trợ sửa cấu hình API trực tuyến. Hãy cập nhật tùy chọn khởi động máy chủ hoặc tệp cấu hình rồi khởi động lại dịch vụ để áp dụng thay đổi.", "webMcpApiTokenHint": "Nếu đã cấu hình API Token, bạn cần truyền thông tin xác thực khi kết nối tác nhân", "useMcpApiTokenTemplate": "Chuyển sang cấu hình có xác thực", - "mcpRequiresTcp": "Hướng dẫn kết nối tác nhân khả dụng khi giao thức API là TCP." + "mcpRequiresTcp": "Hướng dẫn kết nối tác nhân khả dụng khi giao thức API là TCP.", + "emptyTaskCreateHint": "Nhấn {action} để bắt đầu tải xuống" } diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index 96e8a184..e199a7f7 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -18,6 +18,7 @@ "exit": "退出", "pressBackAgainToExit": "再按一次返回键退出应用", "create": "创建任务", + "createAction": "创建", "directDownload": "直接下载", "advancedOptions": "高级选项", "followSettings": "跟随设置", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "保存为默认下载目录", "downloadDirValid": "请选择下载目录", "connections": "连接数", + "maxRetries": "最大重试次数", "useServerCtime": "创建文件使用服务器时间", "maxRunning": "最大下载数", "defaultDirectDownload": "默认启用直接下载", @@ -102,6 +104,7 @@ "extensions": "扩展", "extensionInstallUrl": "安装链接", "extensionInstallSuccess": "安装成功", + "extensionUpdateConfirm": "确定将“{name}”更新到最新版本吗?", "extensionUpdateSuccess": "更新成功", "extensionDelete": "删除扩展", "extensionAlreadyLatest": "已经是最新版本", @@ -352,7 +355,7 @@ "resolvedFileCount": "已解析 {count} 个文件", "resourceHasNoFiles": "该资源没有可选择的文件列表", "noFiles": "没有文件", - "createHistory": "创建历史", + "historyLinks": "历史链接", "noHistory": "暂无历史记录", "directDownloadDescription": "跳过解析并立即创建任务", "taskProgress": "任务进度", @@ -441,5 +444,6 @@ "webMcpApiTokenHint": "若已配置 API Token,接入 Agent 时需要传递认证信息", "useMcpApiTokenTemplate": "切换为认证配置", "webApiConfigReadOnlyHelp": "Web 版本不支持在线修改 API 配置。请修改服务端启动参数或配置文件,并重启服务后使配置生效。", - "mcpRequiresTcp": "API 通讯协议为 TCP 时才能使用 Agent 接入指引。" + "mcpRequiresTcp": "API 通讯协议为 TCP 时才能使用 Agent 接入指引。", + "emptyTaskCreateHint": "点击{action}开始下载" } diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index c75a84c7..1925dd0f 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -18,6 +18,7 @@ "exit": "退出", "pressBackAgainToExit": "再按一次返回鍵退出應用程式", "create": "建立任務", + "createAction": "建立", "directDownload": "直接下載", "advancedOptions": "進階選項", "followSettings": "跟隨設定", @@ -37,6 +38,7 @@ "rememberLastDownloadDir": "儲存為預設下載目錄", "downloadDirValid": "請選擇下載目錄", "connections": "連接數", + "maxRetries": "最大重試次數", "useServerCtime": "使用伺服器時間作為檔案建立時間", "maxRunning": "最大執行任務數", "defaultDirectDownload": "預設勾選直接下載", @@ -102,6 +104,7 @@ "extensions": "擴充功能", "extensionInstallUrl": "安裝 URL", "extensionInstallSuccess": "安裝成功", + "extensionUpdateConfirm": "確定將「{name}」更新到最新版本嗎?", "extensionUpdateSuccess": "更新成功", "extensionDelete": "刪除擴充功能", "extensionAlreadyLatest": "已是最新版本", @@ -352,7 +355,7 @@ "resolvedFileCount": "已解析 {count} 個檔案", "resourceHasNoFiles": "該資源沒有可選擇的檔案列表", "noFiles": "沒有檔案", - "createHistory": "建立歷史記錄", + "historyLinks": "歷史連結", "noHistory": "暫無歷史記錄", "directDownloadDescription": "跳過解析並立即建立任務", "taskProgress": "任務進度", @@ -441,5 +444,6 @@ "webApiConfigReadOnlyHelp": "Web 版本不支援線上修改 API 設定。請修改伺服器啟動參數或設定檔,並重新啟動服務後使設定生效。", "webMcpApiTokenHint": "若已設定 API Token,接入 Agent 時需要傳遞認證資訊", "useMcpApiTokenTemplate": "切換為認證設定", - "mcpRequiresTcp": "API 通訊協定為 TCP 時才能使用 Agent 接入指引。" + "mcpRequiresTcp": "API 通訊協定為 TCP 時才能使用 Agent 接入指引。", + "emptyTaskCreateHint": "點擊{action}開始下載" } diff --git a/ui/flutter/lib/main.dart b/ui/flutter/lib/main.dart index 5783d4fd..e8caba80 100644 --- a/ui/flutter/lib/main.dart +++ b/ui/flutter/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:app_links/app_links.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -17,6 +18,11 @@ import 'util/util.dart'; Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); + if (Util.isMacos()) { + // Gopeed is not sandboxed. Configure each engine before the main/child + // window split so native file and directory dialogs work in both windows. + await FilePicker.skipEntitlementsChecks(); + } if (Util.isAndroid()) { FlutterForegroundTask.initCommunicationPort(); } diff --git a/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart b/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart index 4673ec49..87357fa0 100644 --- a/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart +++ b/ui/flutter/lib/shared/navigation/app_exit_confirmation_controller.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -7,18 +9,40 @@ import '../widgets/app_toast.dart'; class AppExitConfirmationController { static const confirmationWindow = Duration(seconds: 2); - DateTime? _lastBackAt; + OverlayEntry? _toast; + Timer? _timer; Future handleBack(BuildContext context) async { - final now = DateTime.now(); - final lastBackAt = _lastBackAt; - if (lastBackAt != null && now.difference(lastBackAt) <= confirmationWindow) { - _lastBackAt = null; + if (_toast != null) { + dispose(); await SystemNavigator.pop(); return; } - _lastBackAt = now; - showAppToast(context, context.l10n.pressBackAgainToExit); + final message = context.l10n.pressBackAgainToExit; + final themes = InheritedTheme.capture(from: context, to: Overlay.of(context, rootOverlay: true).context); + _toast = OverlayEntry( + builder: (_) => IgnorePointer( + child: SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(24), + child: themes.wrap(AppToastContent(message: message)), + ), + ), + ), + ), + ); + Overlay.of(context, rootOverlay: true).insert(_toast!); + _timer = Timer(confirmationWindow, dispose); + } + + void dispose() { + _timer?.cancel(); + _timer = null; + _toast?.remove(); + _toast?.dispose(); + _toast = null; } } diff --git a/ui/flutter/lib/shared/services/download_directory_picker.dart b/ui/flutter/lib/shared/services/download_directory_picker.dart index 3b73ccc2..5c8b1ea5 100644 --- a/ui/flutter/lib/shared/services/download_directory_picker.dart +++ b/ui/flutter/lib/shared/services/download_directory_picker.dart @@ -58,7 +58,7 @@ class DownloadDirectoryPicker { return _pickAndroid(context, currentPath: currentPath); } if (_platform == TargetPlatform.iOS) return null; - return FilePicker.platform.getDirectoryPath(); + return FilePicker.getDirectoryPath(); } static Future _pickAndroid(BuildContext context, {required String currentPath}) async { diff --git a/ui/flutter/lib/shared/theme/app_component_themes.dart b/ui/flutter/lib/shared/theme/app_component_themes.dart index 5b0915a0..bc6611db 100644 --- a/ui/flutter/lib/shared/theme/app_component_themes.dart +++ b/ui/flutter/lib/shared/theme/app_component_themes.dart @@ -18,42 +18,50 @@ class AppComponentThemes extends StatelessWidget { align: 1, border: Border.all(color: theme.colorScheme.ring.withValues(alpha: 0.65), width: 1), ), - child: shad.ComponentTheme( - data: shad.SecondaryButtonTheme( - decoration: (context, states, value) { - final decoration = switch (value) { - final BoxDecoration decoration => decoration, - _ => BoxDecoration(borderRadius: BorderRadius.circular(theme.radiusMd)), - }; - if (states.contains(WidgetState.disabled)) { - return decoration.copyWith(color: palette.surfaceSoft); - } - if (theme.brightness == Brightness.light) { - final background = Color.alphaBlend(palette.textPrimary.withValues(alpha: 0.08), palette.surfaceSoft); - final hoverBackground = Color.alphaBlend( - palette.textPrimary.withValues(alpha: 0.13), - palette.surfaceSoft, - ); - return decoration.copyWith(color: states.contains(WidgetState.hovered) ? hoverBackground : background); - } - return value; - }, - textStyle: (context, states, value) => states.contains(WidgetState.disabled) - ? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62)) - : value, - iconTheme: (context, states, value) => states.contains(WidgetState.disabled) - ? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62)) - : value, + child: shad.ComponentTheme( + data: shad.TextFieldTheme( + filled: true, + border: Border.all(color: palette.border), + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + padding: AppDesignTokens.textFieldPadding, ), - child: shad.ComponentTheme( - data: shad.CheckboxTheme( - size: AppDesignTokens.checkboxSize, - gap: AppDesignTokens.checkboxLabelGap, - borderColor: theme.brightness == Brightness.dark ? palette.textMuted : palette.border, + child: shad.ComponentTheme( + data: shad.SecondaryButtonTheme( + decoration: (context, states, value) { + final decoration = switch (value) { + final BoxDecoration decoration => decoration, + _ => BoxDecoration(borderRadius: BorderRadius.circular(theme.radiusMd)), + }; + if (states.contains(WidgetState.disabled)) { + return decoration.copyWith(color: palette.surfaceSoft); + } + if (theme.brightness == Brightness.light) { + final background = Color.alphaBlend(palette.textPrimary.withValues(alpha: 0.08), palette.surfaceSoft); + final hoverBackground = Color.alphaBlend( + palette.textPrimary.withValues(alpha: 0.13), + palette.surfaceSoft, + ); + return decoration.copyWith(color: states.contains(WidgetState.hovered) ? hoverBackground : background); + } + return value; + }, + textStyle: (context, states, value) => states.contains(WidgetState.disabled) + ? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62)) + : value, + iconTheme: (context, states, value) => states.contains(WidgetState.disabled) + ? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62)) + : value, ), - child: shad.ComponentTheme( - data: const shad.ToastTheme(toastConstraints: BoxConstraints(maxWidth: 420)), - child: child, + child: shad.ComponentTheme( + data: shad.CheckboxTheme( + size: AppDesignTokens.checkboxSize, + gap: AppDesignTokens.checkboxLabelGap, + borderColor: theme.brightness == Brightness.dark ? palette.textMuted : palette.border, + ), + child: shad.ComponentTheme( + data: const shad.ToastTheme(toastConstraints: BoxConstraints(maxWidth: 420)), + child: child, + ), ), ), ), diff --git a/ui/flutter/lib/shared/theme/app_design_tokens.dart b/ui/flutter/lib/shared/theme/app_design_tokens.dart index 7847316d..615fb139 100644 --- a/ui/flutter/lib/shared/theme/app_design_tokens.dart +++ b/ui/flutter/lib/shared/theme/app_design_tokens.dart @@ -10,6 +10,7 @@ class AppDesignTokens { static const double contentHeaderHeight = 48; static const double taskRowHeight = 72; static const double controlRadius = 4; + static const double dialogActionMinWidth = 100; static const double checkboxSize = 18; static const double checkboxLabelGap = 10; static const double taskDetailsDrawerMinWidth = 440; @@ -21,6 +22,9 @@ class AppDesignTokens { static const double settingsFormControlWidth = 420; static const double settingsNumberControlWidth = 168; static const double settingsItemMinLabelWidth = 160; + static const EdgeInsets textFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 11); + static const EdgeInsets compactTextFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 6); + static const EdgeInsets numberTextFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 10); static const EdgeInsets pagePadding = EdgeInsets.symmetric(horizontal: 32); static const EdgeInsets sidebarPadding = EdgeInsets.symmetric(horizontal: 16); diff --git a/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart b/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart index 9ccf53ca..332448d4 100644 --- a/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart +++ b/ui/flutter/lib/shared/widgets/app_http_headers_editor.dart @@ -5,6 +5,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import '../theme/app_design_tokens.dart'; import '../theme/app_palette.dart'; import '../../l10n/l10n.dart'; +import 'app_text_field.dart'; class AppHttpHeadersController extends ChangeNotifier { AppHttpHeadersController({Map? headers, Iterable defaultNames = const []}) { @@ -171,7 +172,7 @@ class _HeaderTextField extends StatelessWidget { @override Widget build(BuildContext context) { final palette = AppPalette.of(context); - return shad.TextField( + return AppTextField( controller: controller, hintText: hintText, filled: true, diff --git a/ui/flutter/lib/shared/widgets/app_loading_button.dart b/ui/flutter/lib/shared/widgets/app_loading_button.dart index dd5dde4a..f886204a 100644 --- a/ui/flutter/lib/shared/widgets/app_loading_button.dart +++ b/ui/flutter/lib/shared/widgets/app_loading_button.dart @@ -30,7 +30,15 @@ class AppLoadingButton extends StatelessWidget { final palette = AppPalette.of(context); final replaceChild = loading && icon == null; final leading = loading && icon != null ? _LoadingIcon(variant: variant) : icon; - final effectiveChild = replaceChild ? _LoadingIcon(variant: variant) : child; + final effectiveChild = replaceChild + ? Stack( + alignment: Alignment.center, + children: [ + ExcludeSemantics(child: Opacity(opacity: 0, child: child)), + Positioned.fill(child: _LoadingIcon(variant: variant)), + ], + ) + : child; final content = leading == null ? effectiveChild : Row( @@ -38,7 +46,7 @@ class AppLoadingButton extends StatelessWidget { children: [ leading, const SizedBox(width: AppDesignTokens.space8), - effectiveChild, + Flexible(child: effectiveChild), ], ); final effectiveOnPressed = loading ? null : onPressed; diff --git a/ui/flutter/lib/shared/widgets/app_number_input.dart b/ui/flutter/lib/shared/widgets/app_number_input.dart index 50161585..70d4bb52 100644 --- a/ui/flutter/lib/shared/widgets/app_number_input.dart +++ b/ui/flutter/lib/shared/widgets/app_number_input.dart @@ -2,6 +2,9 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; +import '../theme/app_design_tokens.dart'; +import 'app_text_field.dart'; + /// A bounded numeric text field with the app's standard spinner controls. class AppNumberInput extends StatefulWidget { const AppNumberInput({ @@ -80,7 +83,7 @@ class _AppNumberInputState extends State { FilteringTextInputFormatter.digitsOnly, _NumericalRangeFormatter(min: widget.min.toInt(), max: widget.max?.toInt()), ]; - return shad.TextField( + return AppTextField( key: widget.fieldKey, controller: widget.controller, hintText: widget.hintText, @@ -89,6 +92,7 @@ class _AppNumberInputState extends State { : TextInputType.number, inputFormatters: formatters, enabled: widget.enabled, + padding: AppDesignTokens.numberTextFieldPadding, features: [ shad.InputFeature.spinner( step: widget.step, diff --git a/ui/flutter/lib/shared/widgets/app_path_picker_field.dart b/ui/flutter/lib/shared/widgets/app_path_picker_field.dart index 1cd710a9..e14ba37c 100644 --- a/ui/flutter/lib/shared/widgets/app_path_picker_field.dart +++ b/ui/flutter/lib/shared/widgets/app_path_picker_field.dart @@ -1,9 +1,12 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart' show Icons; import 'package:flutter/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import '../../core/utils/breakpoints.dart'; +import '../../util/util.dart'; import '../services/download_directory_picker.dart'; +import 'app_text_field.dart'; import 'app_tooltip.dart'; enum AppPathPickerButtonStyle { ghost, outline } @@ -23,7 +26,28 @@ class AppPathPickerField extends StatefulWidget { this.padding, this.onChanged, this.pickerButtonStyle = AppPathPickerButtonStyle.ghost, - }) : platformDownloadDirectory = false, + }) : platformFile = false, + platformDownloadDirectory = false, + onDirectoryPicked = null, + allowAndroidEditing = false; + + /// Selects a native desktop file path; Web and mobile accept manual paths only. + const AppPathPickerField.file({ + super.key, + required this.controller, + this.fieldKey, + this.pickerKey, + this.hintText, + this.desktopWidth, + this.filled, + this.border, + this.borderRadius, + this.padding, + this.onChanged, + this.pickerButtonStyle = AppPathPickerButtonStyle.outline, + }) : onPick = null, + platformFile = true, + platformDownloadDirectory = false, onDirectoryPicked = null, allowAndroidEditing = false; @@ -43,6 +67,7 @@ class AppPathPickerField extends StatefulWidget { this.onChanged, this.pickerButtonStyle = AppPathPickerButtonStyle.outline, }) : onPick = null, + platformFile = false, platformDownloadDirectory = true; final TextEditingController controller; @@ -57,6 +82,7 @@ class AppPathPickerField extends StatefulWidget { final EdgeInsetsGeometry? padding; final ValueChanged? onChanged; final AppPathPickerButtonStyle pickerButtonStyle; + final bool platformFile; final bool platformDownloadDirectory; final ValueChanged? onDirectoryPicked; final bool allowAndroidEditing; @@ -96,6 +122,16 @@ class _AppPathPickerFieldState extends State { setState(() => _tooltipPath = path); } + Future _pickFile() async { + if (!Util.isDesktop()) return; + final file = await FilePicker.pickFile(); + if (!mounted) return; + final path = file?.path; + if (path == null || path.isEmpty || path == widget.controller.text) return; + widget.controller.text = path; + widget.onChanged?.call(path); + } + Future _pickPlatformDirectory() async { final path = await DownloadDirectoryPicker.pick(context, currentPath: widget.controller.text.trim()); if (!mounted || path == null || path.isEmpty || path == widget.controller.text) return; @@ -109,6 +145,8 @@ class _AppPathPickerFieldState extends State { final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; final onPick = widget.platformDownloadDirectory ? (DownloadDirectoryPicker.canPick ? _pickPlatformDirectory : null) + : widget.platformFile + ? (Util.isDesktop() ? _pickFile : null) : widget.onPick; return SizedBox( width: desktop ? widget.desktopWidth : double.infinity, @@ -119,7 +157,7 @@ class _AppPathPickerFieldState extends State { onEnter: (_) => _syncTooltipPath(), child: AppTooltip( message: _tooltipPath, - child: shad.TextField( + child: AppTextField( key: widget.fieldKey, controller: widget.controller, hintText: widget.hintText, diff --git a/ui/flutter/lib/shared/widgets/app_text_field.dart b/ui/flutter/lib/shared/widgets/app_text_field.dart new file mode 100644 index 00000000..52d8ba79 --- /dev/null +++ b/ui/flutter/lib/shared/widgets/app_text_field.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart' as material show AdaptiveTextSelectionToolbar; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +/// The app-wide text field. +/// +/// shadcn_flutter's custom context menu is rendered in a full-screen overlay +/// that can cover the app with an opaque surface. Use Flutter's adaptive +/// platform toolbar everywhere so long-press and right-click text actions have +/// consistent native behavior on mobile, desktop, and web. +class AppTextField extends shad.TextField { + const AppTextField({ + super.key, + super.groupId, + super.controller, + super.initialValue, + super.focusNode, + super.undoController, + super.decoration, + super.padding, + super.placeholder, + super.crossAxisAlignment, + super.clearButtonSemanticLabel, + super.keyboardType, + super.textInputAction, + super.textCapitalization, + super.style, + super.strutStyle, + super.textAlign, + super.textAlignVertical, + super.textDirection, + super.readOnly, + super.showCursor, + super.autofocus, + super.obscuringCharacter, + super.obscureText, + super.autocorrect, + super.smartDashesType, + super.smartQuotesType, + super.enableSuggestions, + super.maxLines, + super.minLines, + super.expands, + super.maxLength, + super.maxLengthEnforcement, + super.onChanged, + super.onEditingComplete, + super.onSubmitted, + super.onTapOutside, + super.onTapUpOutside, + super.inputFormatters, + super.enabled, + super.cursorWidth, + super.cursorHeight, + super.cursorRadius, + super.cursorOpacityAnimates, + super.cursorColor, + super.selectionHeightStyle, + super.selectionWidthStyle, + super.keyboardAppearance, + super.scrollPadding, + super.enableInteractiveSelection, + super.selectionControls, + super.dragStartBehavior, + super.scrollController, + super.scrollPhysics, + super.onTap, + super.autofillHints, + super.clipBehavior, + super.restorationId, + super.stylusHandwritingEnabled, + super.enableIMEPersonalizedLearning, + super.contentInsertionConfiguration, + super.contextMenuBuilder = appTextFieldContextMenuBuilder, + super.hintText, + super.border, + super.borderRadius, + super.filled, + super.statesController, + super.magnifierConfiguration, + super.spellCheckConfiguration, + super.features, + super.submitFormatters, + super.skipInputFeatureFocusTraversal, + super.onDragSelectionStart, + super.onDragSelectionUpdate, + super.onDragSelectionEnd, + }); +} + +Widget appTextFieldContextMenuBuilder(BuildContext context, EditableTextState editableTextState) { + // Match Flutter's platform text-menu policy without falling back to + // shadcn_flutter's full-screen overlay. EditableText bypasses this builder + // on Web while the browser context menu is enabled. + if (SystemContextMenu.isSupportedByField(editableTextState)) { + return SystemContextMenu.editableText(editableTextState: editableTextState); + } + return material.AdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState); +} diff --git a/ui/flutter/lib/shared/widgets/app_toast.dart b/ui/flutter/lib/shared/widgets/app_toast.dart index eb896516..265bd2f0 100644 --- a/ui/flutter/lib/shared/widgets/app_toast.dart +++ b/ui/flutter/lib/shared/widgets/app_toast.dart @@ -11,11 +11,6 @@ enum AppToastType { info, success, error } /// short messages stay compact while long messages wrap before becoming wider /// than the configured maximum. Errors and normal notices use the same layout. void showAppToast(BuildContext context, String message, {AppToastType type = AppToastType.info}) { - final palette = AppPalette.of(context); - final borderColor = switch (type) { - AppToastType.error => palette.error.withValues(alpha: 0.55), - AppToastType.success || AppToastType.info => palette.border, - }; shad.showToast( context: context, location: shad.ToastLocation.topCenter, @@ -23,16 +18,34 @@ void showAppToast(BuildContext context, String message, {AppToastType type = App alignment: Alignment.topCenter, widthFactor: 1, heightFactor: 1, - child: Container( - key: const ValueKey('app-toast-content'), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - decoration: BoxDecoration( - color: palette.cardBg, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: borderColor), - ), - child: Text(message, style: TextStyle(color: palette.textPrimary, fontSize: 13, height: 1.2)), - ), + child: AppToastContent(message: message, type: type), ), ); } + +/// Shared toast surface for edge notifications and centered exit prompts. +class AppToastContent extends StatelessWidget { + const AppToastContent({super.key, required this.message, this.type = AppToastType.info}); + + final String message; + final AppToastType type; + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final borderColor = switch (type) { + AppToastType.error => palette.error.withValues(alpha: 0.55), + AppToastType.success || AppToastType.info => palette.border, + }; + return Container( + key: const ValueKey('app-toast-content'), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor), + ), + child: Text(message, style: TextStyle(color: palette.textPrimary, fontSize: 13, height: 1.2)), + ); + } +} diff --git a/ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart b/ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart index 906b035d..4aa5b804 100644 --- a/ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart +++ b/ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart @@ -251,7 +251,7 @@ Future _getManifestPath(Browser browser) async { Future _checkWindowsRegistry(String keyPath) async { try { - final key = Registry.openPath(RegistryHive.localMachine, path: keyPath); + final key = LOCAL_MACHINE.open(keyPath); key.close(); return true; } catch (e) { diff --git a/ui/flutter/lib/util/scheme_register/entry/scheme_register_native.dart b/ui/flutter/lib/util/scheme_register/entry/scheme_register_native.dart index 7f2c154b..c1f8aa56 100644 --- a/ui/flutter/lib/util/scheme_register/entry/scheme_register_native.dart +++ b/ui/flutter/lib/util/scheme_register/entry/scheme_register_native.dart @@ -26,7 +26,7 @@ void doRegisterUrlScheme(String scheme) { void doUnregisterUrlScheme(String scheme) { if (Util.isWindows()) { - Registry.currentUser.deleteKey('Software\\Classes\\$scheme', recursive: true); + CURRENT_USER.removeSubkey('Software\\Classes\\$scheme'); return; } @@ -66,8 +66,8 @@ void doUnregisterDefaultTorrentClient() { if (Util.isWindows()) { doUnregisterUrlScheme("magnet"); - Registry.currentUser.deleteKey(_torrentRegKey, recursive: true); - Registry.currentUser.deleteKey(_torrentAppRegKey, recursive: true); + CURRENT_USER.removeSubkey(_torrentRegKey); + CURRENT_USER.removeSubkey(_torrentAppRegKey); return; } diff --git a/ui/flutter/lib/util/win32.dart b/ui/flutter/lib/util/win32.dart index e1324972..2d8ecd8d 100644 --- a/ui/flutter/lib/util/win32.dart +++ b/ui/flutter/lib/util/win32.dart @@ -3,12 +3,14 @@ import 'package:win32_registry/win32_registry.dart'; /// Check registry key /// If the key does not exist or the value is different, return false bool checkRegistry(String keyPath, String valueName, String value) { - RegistryKey regKey; + RegistryKey? regKey; try { - regKey = Registry.openPath(RegistryHive.currentUser, path: keyPath); - return regKey.getValueAsString(valueName) == value; + regKey = CURRENT_USER.open(keyPath); + return regKey.getString(valueName) == value; } catch (e) { return false; + } finally { + regKey?.close(); } } @@ -16,15 +18,15 @@ bool checkRegistry(String keyPath, String valueName, String value) { /// If the key does not exist, create it /// If the value does not exist or is different, update it void upsertRegistry(String keyPath, String valueName, String value) { - RegistryKey regKey; + final regKey = CURRENT_USER.create( + keyPath, + config: const RegistryOpenConfig(access: RegistryAccess.all, create: true), + ); try { - regKey = Registry.openPath(RegistryHive.currentUser, path: keyPath, desiredAccessRights: AccessRights.allAccess); - } catch (e) { - regKey = Registry.currentUser.createKey(keyPath); + if (regKey.getString(valueName) != value) { + regKey.setValue(valueName, RegistryValue.string(value)); + } + } finally { + regKey.close(); } - - if (regKey.getValueAsString(valueName) != value) { - regKey.createValue(RegistryValue(valueName, RegistryValueType.string, value)); - } - regKey.close(); } diff --git a/ui/flutter/linux/runner/my_application.cc b/ui/flutter/linux/runner/my_application.cc index d6355099..895f8fd1 100644 --- a/ui/flutter/linux/runner/my_application.cc +++ b/ui/flutter/linux/runner/my_application.cc @@ -48,11 +48,11 @@ static void my_application_activate(GApplication* application) { if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "gopeed"); + gtk_header_bar_set_title(header_bar, "Gopeed"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "gopeed"); + gtk_window_set_title(window, "Gopeed"); } gtk_window_set_default_size(window, 1280, 720); diff --git a/ui/flutter/macos/Runner/AppDelegate.swift b/ui/flutter/macos/Runner/AppDelegate.swift index 91c1ed49..5e0636a3 100644 --- a/ui/flutter/macos/Runner/AppDelegate.swift +++ b/ui/flutter/macos/Runner/AppDelegate.swift @@ -26,16 +26,18 @@ class AppDelegate: FlutterAppDelegate { } override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - if !flag { - for window in NSApp.windows { - if !window.isVisible { - window.setIsVisible(true) - } - window.makeKeyAndOrderFront(self) + // Closed child windows can remain in NSApp.windows. Restoring every + // window resurrects completed create-task forms with their last frame. + // Use the main window even when an unfinished child is still visible. + if let window = sender.windows.first(where: { $0 is MainFlutterWindow }) { + if window.isMiniaturized { + window.deminiaturize(self) } - NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(self) + sender.activate(ignoringOtherApps: true) } - return true + // We handled reopening; AppKit must not restore other retained windows. + return false } override func application(_ sender: NSApplication, openFile filename: String) -> Bool { diff --git a/ui/flutter/macos/Runner/Base.lproj/MainMenu.xib b/ui/flutter/macos/Runner/Base.lproj/MainMenu.xib index 80e867a4..2070a6c6 100644 --- a/ui/flutter/macos/Runner/Base.lproj/MainMenu.xib +++ b/ui/flutter/macos/Runner/Base.lproj/MainMenu.xib @@ -330,7 +330,7 @@ - + diff --git a/ui/flutter/macos/Runner/Info.plist b/ui/flutter/macos/Runner/Info.plist index cee06fe7..48add6d5 100644 --- a/ui/flutter/macos/Runner/Info.plist +++ b/ui/flutter/macos/Runner/Info.plist @@ -2,6 +2,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoadsInWebContent + + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock index 4922da34..06828e78 100644 --- a/ui/flutter/pubspec.lock +++ b/ui/flutter/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "10.0.1" + android_file_picker: + dependency: transitive + description: + name: android_file_picker + sha256: "0d84bb91fb78af33756107f00cf71ee084474169d481b882b335c3006077a913" + url: "https://pub.dev" + source: hosted + version: "1.1.0" animation_kit: dependency: transitive description: @@ -42,7 +50,7 @@ packages: source: hosted version: "1.0.3" app_links_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: app_links_platform_interface sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" @@ -245,10 +253,10 @@ packages: dependency: "direct main" description: name: dart_ipc - sha256: "6cad558cda5304017c1f581df4c96fd4f8e4ee212aae7bfa4357716236faa9ba" + sha256: "3b372078dcc542b97189da4df2b08c1fc5ab0123ecdce37d400ce85ab651b273" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "2.0.2" dart_style: dependency: transitive description: @@ -293,18 +301,18 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "72d146c6d7098689ff5c5f66bcf593ac11efc530095385356e131070333e64da" + sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442" url: "https://pub.dev" source: hosted - version: "11.3.0" + version: "13.2.0" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface - sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46" url: "https://pub.dev" source: hosted - version: "7.0.3" + version: "8.1.0" dio: dependency: "direct main" description: @@ -361,30 +369,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" ffigen: dependency: "direct dev" description: name: ffigen - sha256: d3e76c2ad48a4e7f93a29a162006f00eba46ce7c08194a77bb5c5e97d1b5ff0a + sha256: "56fc0be88937409839a59c133dfdee4165e80da324c9e39f59013060249ffb44" url: "https://pub.dev" source: hosted - version: "8.0.2" + version: "21.0.0" file: dependency: transitive description: name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "7.0.1" file_picker: dependency: "direct main" description: name: file_picker - sha256: "7423298f08f6fc8cce05792bae329f9a93653fc9c08712831b1a55540127995d" + sha256: "0631f51bbc2e1184bdc9e79384db158dc7830e15b3001feeca700e86563f4d24" url: "https://pub.dev" source: hosted - version: "9.0.2" + version: "12.2.0" + file_picker_darwin: + dependency: transitive + description: + name: file_picker_darwin + sha256: "5c32c9400f5c8976c9dd7aa34693c7d2f0c853c28704a1c1409029cb760f5251" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_picker_linux: + dependency: transitive + description: + name: file_picker_linux + sha256: bd52ff1e0048f29df95f913c55ad2991c72791d93e6c42241912c4bdee946cb9 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_picker_platform_interface: + dependency: transitive + description: + name: file_picker_platform_interface + sha256: d35d8cb68446fae921335bb61501d66cb2469d74c8f3103b8c6ccdbe3fe5afd8 + url: "https://pub.dev" + source: hosted + version: "3.3.0" + file_picker_web: + dependency: transitive + description: + name: file_picker_web + sha256: "935560a9d29fa6f006f2855addebb88e88d438e13627d4cc24187033c106f227" + url: "https://pub.dev" + source: hosted + version: "3.1.0" fixnum: dependency: transitive description: @@ -409,18 +457,20 @@ packages: flutter_inappwebview: dependency: "direct main" description: - name: flutter_inappwebview - sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" - url: "https://pub.dev" - source: hosted + path: flutter_inappwebview + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + resolved-ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + url: "https://github.com/GopeedLab/flutter_inappwebview.git" + source: git version: "6.1.5" flutter_inappwebview_android: - dependency: transitive + dependency: "direct overridden" description: - name: flutter_inappwebview_android - sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" - url: "https://pub.dev" - source: hosted + path: flutter_inappwebview_android + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + resolved-ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + url: "https://github.com/GopeedLab/flutter_inappwebview.git" + source: git version: "1.1.3" flutter_inappwebview_internal_annotations: dependency: transitive @@ -431,28 +481,31 @@ packages: source: hosted version: "1.3.0" flutter_inappwebview_ios: - dependency: transitive + dependency: "direct overridden" description: - name: flutter_inappwebview_ios - sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" - url: "https://pub.dev" - source: hosted + path: flutter_inappwebview_ios + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + resolved-ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + url: "https://github.com/GopeedLab/flutter_inappwebview.git" + source: git version: "1.1.2" flutter_inappwebview_macos: - dependency: transitive + dependency: "direct overridden" description: - name: flutter_inappwebview_macos - sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 - url: "https://pub.dev" - source: hosted + path: flutter_inappwebview_macos + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + resolved-ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + url: "https://github.com/GopeedLab/flutter_inappwebview.git" + source: git version: "1.1.2" flutter_inappwebview_platform_interface: - dependency: transitive + dependency: "direct overridden" description: - name: flutter_inappwebview_platform_interface - sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 - url: "https://pub.dev" - source: hosted + path: flutter_inappwebview_platform_interface + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + resolved-ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + url: "https://github.com/GopeedLab/flutter_inappwebview.git" + source: git version: "1.3.0+1" flutter_inappwebview_web: dependency: transitive @@ -541,14 +594,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.12" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" - url: "https://pub.dev" - source: hosted - version: "2.0.35" flutter_riverpod: dependency: "direct main" description: @@ -755,11 +800,12 @@ packages: launch_at_startup: dependency: "direct main" description: - name: launch_at_startup - sha256: "1f8a75520913d1038630049e6c44a2575a23ffd28cc8b14fdf37401d1d21de84" - url: "https://pub.dev" - source: hosted - version: "0.3.1" + path: "." + ref: "7ac6f842be974aa08dcd21cab6efa66a3749f2b2" + resolved-ref: "7ac6f842be974aa08dcd21cab6efa66a3749f2b2" + url: "https://github.com/GopeedLab/launch_at_startup.git" + source: git + version: "0.6.0" leak_tracker: dependency: transitive description: @@ -856,6 +902,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 + url: "https://pub.dev" + source: hosted + version: "0.19.2" node_preamble: dependency: transitive description: @@ -932,18 +986,18 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" url: "https://pub.dev" source: hosted - version: "8.3.1" + version: "10.2.1" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "4.1.0" path: dependency: "direct main" description: @@ -1237,18 +1291,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c" url: "https://pub.dev" source: hosted - version: "10.1.4" + version: "13.3.0" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "7.2.0" shared_preferences: dependency: transitive description: @@ -1659,18 +1713,18 @@ packages: dependency: transitive description: name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d url: "https://pub.dev" source: hosted - version: "5.15.0" + version: "6.4.0" win32_registry: dependency: "direct main" description: name: win32_registry - sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904" url: "https://pub.dev" source: hosted - version: "1.1.5" + version: "3.0.3" window_manager: dependency: "direct main" description: @@ -1679,6 +1733,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.2" + windows_file_picker: + dependency: transitive + description: + name: windows_file_picker + sha256: d999c1c085374724668af0b674a9758d96255c50933faa2ac1cc51eff8308caf + url: "https://pub.dev" + source: hosted + version: "1.2.0" xdg_directories: dependency: transitive description: diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index 6bca2e53..405a645f 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2.0.0-beta.1+1 +version: 2.0.0-beta.2+2 environment: sdk: ^3.11.4 @@ -44,21 +44,24 @@ dependencies: ffi: ^2.1.3 crypto: ^3.0.6 logger: ^1.4.0 - package_info_plus: ^8.1.0 + package_info_plus: ^10.2.1 pub_semver: ^2.2.0 - device_info_plus: ^11.1.0 - win32_registry: ^1.1.5 - launch_at_startup: ^0.3.1 + device_info_plus: ^13.2.0 + win32_registry: ^3.0.3 + launch_at_startup: + git: + url: https://github.com/GopeedLab/launch_at_startup.git + ref: 7ac6f842be974aa08dcd21cab6efa66a3749f2b2 app_links: ^6.3.3 share_handler: ^0.0.22 - share_plus: ^10.1.0 + share_plus: ^13.3.0 uri_content: ^3.1.3 - dart_ipc: ^1.0.1 + dart_ipc: ^2.0.2 flutter_inappwebview: ^6.1.5 flutter_local_notifications: 20.1.0 desktop_drop: ^0.4.4 external_path: ^2.2.0 - file_picker: 9.0.2 + file_picker: 12.2.0 open_dir: ^0.0.2+1 open_filex: ^4.7.0 permission_handler: ^11.3.1 @@ -83,6 +86,32 @@ dependencies: go_router: ^16.2.1 dependency_overrides: + # Public persistent-profile and proxy APIs; keep all packages on one commit. + flutter_inappwebview: + git: + url: https://github.com/GopeedLab/flutter_inappwebview.git + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + path: flutter_inappwebview + flutter_inappwebview_platform_interface: + git: + url: https://github.com/GopeedLab/flutter_inappwebview.git + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + path: flutter_inappwebview_platform_interface + flutter_inappwebview_macos: + git: + url: https://github.com/GopeedLab/flutter_inappwebview.git + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + path: flutter_inappwebview_macos + flutter_inappwebview_ios: + git: + url: https://github.com/GopeedLab/flutter_inappwebview.git + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + path: flutter_inappwebview_ios + flutter_inappwebview_android: + git: + url: https://github.com/GopeedLab/flutter_inappwebview.git + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + path: flutter_inappwebview_android # Prevent a native Windows crash when the push notification service is unavailable. # Keep this fork until https://github.com/MaikuB/flutter_local_notifications/pull/2813 is merged and released. flutter_local_notifications: @@ -105,6 +134,7 @@ dependency_overrides: path: permission_handler_windows dev_dependencies: + app_links_platform_interface: ^2.0.2 flutter_test: sdk: flutter @@ -114,7 +144,7 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^6.0.0 - ffigen: ^8.0.2 + ffigen: ^21.0.0 build_runner: ^2.15.1 json_serializable: ^6.14.1 flutter_launcher_icons: ^0.14.4 @@ -208,7 +238,7 @@ ffigen: - "StartAPIServer" - "StopAPIServer" - "RestartAPIServer" - - "Invoke" + - "InvokeAsync" - "SubscribeTaskEvents" - "FreeCString" structs: diff --git a/ui/flutter/test/app/application/app_notification_controller_test.dart b/ui/flutter/test/app/application/app_notification_controller_test.dart new file mode 100644 index 00000000..a74c60d4 --- /dev/null +++ b/ui/flutter/test/app/application/app_notification_controller_test.dart @@ -0,0 +1,139 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/model/downloader_config.dart'; +import 'package:gopeed/app/application/app_notification_controller.dart'; +import 'package:gopeed/app/application/app_runtime_controller.dart'; +import 'package:gopeed/core/common/api_server_state.dart'; +import 'package:gopeed/core/common/start_config.dart'; +import 'package:gopeed/core/common/task_event.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('dexterous.com/flutter/local_notifications'); + final calls = []; + var permissionGranted = true; + late StreamController events; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + MacOSFlutterLocalNotificationsPlugin.registerWith(); + calls.clear(); + permissionGranted = true; + events = StreamController.broadcast(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return permissionGranted; + }); + }); + + tearDown(() { + unawaited(events.close()); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + }); + + Future start({required bool enabled}) async { + final container = ProviderContainer( + overrides: [ + appRuntimeControllerProvider.overrideWith(() => _TestRuntimeController(enabled)), + appNotificationControllerProvider.overrideWith(() => _TestNotificationController(events.stream)), + ], + ); + addTearDown(container.dispose); + await container.read(appRuntimeControllerProvider.future); + await container.read(appNotificationControllerProvider.future); + return container; + } + + Map initialization() => calls.lastWhere((call) => call.method == 'initialize').arguments as Map; + + test('macOS requests alerts and sound when download notifications are enabled at startup', () async { + await start(enabled: true); + + expect(initialization()['requestAlertPermission'], isTrue); + expect(initialization()['requestSoundPermission'], isTrue); + expect(initialization()['requestBadgePermission'], isFalse); + }); + + test('disabled notifications defer authorization until the setting is enabled', () async { + final container = await start(enabled: false); + expect(initialization()['requestAlertPermission'], isFalse); + expect(initialization()['requestSoundPermission'], isFalse); + + final runtime = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController; + runtime.setNotificationsEnabled(true); + await container.pump(); + await container.read(appNotificationControllerProvider.future); + + expect(initialization()['requestAlertPermission'], isTrue); + expect(initialization()['requestSoundPermission'], isTrue); + + runtime.setNotificationsEnabled(false); + await container.pump(); + await container.read(appNotificationControllerProvider.future); + expect(initialization()['requestAlertPermission'], isFalse); + expect(initialization()['requestSoundPermission'], isFalse); + }); + + test('denied macOS authorization does not fail notification controller startup', () async { + permissionGranted = false; + final container = await start(enabled: true); + + expect(container.read(appNotificationControllerProvider).requireValue.started, isTrue); + }); + + test('terminal task events notify once after enabling and stop after disabling', () async { + final container = await start(enabled: false); + final runtime = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController; + const done = TaskEvent(type: TaskEventType.done, taskId: '1', name: 'archive.zip'); + events.add(done); + await Future.delayed(Duration.zero); + expect(calls.where((call) => call.method == 'show'), isEmpty); + + runtime.setNotificationsEnabled(true); + await container.pump(); + await container.read(appNotificationControllerProvider.future); + events.add(done); + events.add(const TaskEvent(type: TaskEventType.error, taskId: '2', name: 'failed.zip')); + await Future.delayed(Duration.zero); + final notifications = calls.where((call) => call.method == 'show').toList(); + expect(notifications, hasLength(2)); + expect(notifications.map((call) => (call.arguments as Map)['body']), ['archive.zip', 'failed.zip']); + + runtime.setNotificationsEnabled(false); + await container.pump(); + await container.read(appNotificationControllerProvider.future); + events.add(done); + await Future.delayed(Duration.zero); + expect(calls.where((call) => call.method == 'show'), hasLength(2)); + }); +} + +class _TestNotificationController extends AppNotificationController { + _TestNotificationController(this.taskEvents); + + @override + final Stream taskEvents; +} + +class _TestRuntimeController extends AppRuntimeController { + _TestRuntimeController(this.enabled); + + final bool enabled; + + @override + Future build() async => _runtime(enabled); + + void setNotificationsEnabled(bool enabled) => state = AsyncData(_runtime(enabled)); + + AppRuntimeState _runtime(bool enabled) => AppRuntimeState( + startConfig: StartConfig(), + apiServerState: ApiServerState.fromJson({}), + downloaderConfig: DownloaderConfig()..extra.desktopNotification = enabled, + ); +} diff --git a/ui/flutter/test/app/application/app_platform_controller_test.dart b/ui/flutter/test/app/application/app_platform_controller_test.dart new file mode 100644 index 00000000..a5795ebb --- /dev/null +++ b/ui/flutter/test/app/application/app_platform_controller_test.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/model/downloader_config.dart'; +import 'package:gopeed/app/application/app_platform_controller.dart'; +import 'package:gopeed/app/application/app_runtime_controller.dart'; +import 'package:gopeed/core/common/api_server_state.dart'; +import 'package:gopeed/core/common/start_config.dart'; +import 'package:gopeed/util/updater.dart'; + +void main() { + test('runtime updates do not rebuild the platform controller', () async { + final container = ProviderContainer( + overrides: [ + appRuntimeControllerProvider.overrideWith(_TestRuntimeController.new), + appPlatformControllerProvider.overrideWith(_TestPlatformController.new), + ], + ); + addTearDown(container.dispose); + final subscription = container.listen(appPlatformControllerProvider, (_, _) {}, fireImmediately: true); + addTearDown(subscription.close); + + await container.read(appPlatformControllerProvider.future); + final platformController = container.read(appPlatformControllerProvider.notifier) as _TestPlatformController; + final runtimeController = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController; + + expect(platformController.buildCount, 1); + + runtimeController.completeRuntime(); + await container.read(appRuntimeControllerProvider.future); + await Future.delayed(Duration.zero); + expect(platformController.updateCheckCount, 1); + + final config = DownloaderConfig.fromJson(runtimeController.state.requireValue.downloaderConfig.toJson()); + config.extra.locale = 'zh'; + runtimeController.replaceDownloaderConfig(config); + await container.pump(); + + expect(platformController.buildCount, 1); + }); +} + +class _TestPlatformController extends AppPlatformController { + int buildCount = 0; + int updateCheckCount = 0; + + @override + bool get isDesktop => false; + + @override + Future build() { + buildCount++; + return super.build(); + } + + @override + Future checkForUpdate() async { + updateCheckCount++; + return null; + } +} + +class _TestRuntimeController extends AppRuntimeController { + final _pendingRuntime = Completer(); + + @override + Future build() => _pendingRuntime.future; + + void completeRuntime() { + _pendingRuntime.complete( + AppRuntimeState( + startConfig: StartConfig(), + apiServerState: const ApiServerState( + enabled: false, + mcpEnabled: false, + running: false, + network: '', + address: '', + runningPort: 0, + pendingApply: false, + lastError: '', + ), + downloaderConfig: DownloaderConfig(), + ), + ); + } +} diff --git a/ui/flutter/test/app/router/app_router_test.dart b/ui/flutter/test/app/router/app_router_test.dart new file mode 100644 index 00000000..bc0bea1c --- /dev/null +++ b/ui/flutter/test/app/router/app_router_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:gopeed/app/router/app_router.dart'; +import 'package:gopeed/features/auth/application/web_auth_controller.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + for (final platform in TargetPlatform.values) { + test('$platform preserves the expected section route stacks', () { + debugDefaultTargetPlatformOverride = platform; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final auth = WebAuthController(); + final router = AppRouter.build(auth); + addTearDown(router.dispose); + addTearDown(auth.dispose); + final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS; + + for (final entry in { + '/': ['/'], + '/extensions': isMobile ? ['/', 'extensions'] : ['/extensions'], + '/extensions/example': isMobile ? ['/', 'extensions', ':id'] : ['/extensions', ':id'], + '/settings': isMobile ? ['/', 'settings'] : ['/settings'], + '/settings/general': isMobile ? ['/', 'settings', ':section'] : ['/settings', ':section'], + '/tasks/example': ['/', 'tasks/:id'], + '/create': ['/', 'create'], + }.entries) { + final match = router.configuration.findMatch(Uri.parse(entry.key)); + expect(match.isError, isFalse); + final shell = match.matches.single as ShellRouteMatch; + expect(shell.matches.map((match) => (match.route as GoRoute).path), entry.value); + } + }); + } +} diff --git a/ui/flutter/test/app/router/app_startup_back_test.dart b/ui/flutter/test/app/router/app_startup_back_test.dart new file mode 100644 index 00000000..6cf89992 --- /dev/null +++ b/ui/flutter/test/app/router/app_startup_back_test.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/app/application/app_runtime_controller.dart'; +import 'package:gopeed/app/router/app_router.dart'; +import 'package:gopeed/features/auth/application/web_auth_controller.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_toast.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +class _StartingRuntime extends AppRuntimeController { + final pending = Completer(); + + @override + Future build() => pending.future; +} + +void main() { + testWidgets( + 'cold startup registers Android back and confirms exit before runtime is ready', + (tester) async { + final platformCalls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + platformCalls.add(call); + return null; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); + final runtime = _StartingRuntime(); + final auth = WebAuthController(); + final router = AppRouter.build(auth); + addTearDown(router.dispose); + addTearDown(auth.dispose); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpWidget( + ProviderScope( + overrides: [appRuntimeControllerProvider.overrideWith(() => runtime)], + child: shad.ShadcnApp.router( + theme: AppTheme.dark(), + materialTheme: AppTheme.materialDark(), + routerConfig: router, + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(find.byType(shad.CircularProgressIndicator), findsOneWidget); + expect( + platformCalls.where((call) => call.method == 'SystemNavigator.setFrameworkHandlesBack').last.arguments, + isTrue, + ); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(platformCalls.where((call) => call.method == 'SystemNavigator.pop'), isEmpty); + expect(find.byType(AppToastContent), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(platformCalls.where((call) => call.method == 'SystemNavigator.pop'), hasLength(1)); + await tester.pumpWidget(const SizedBox()); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); +} diff --git a/ui/flutter/test/app/router/mobile_exit_guard_test.dart b/ui/flutter/test/app/router/mobile_exit_guard_test.dart new file mode 100644 index 00000000..1d70561a --- /dev/null +++ b/ui/flutter/test/app/router/mobile_exit_guard_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:gopeed/app/router/mobile_exit_guard.dart'; +import 'package:gopeed/shared/navigation/app_exit_confirmation_controller.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_toast.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + late List exits; + late GoRouter router; + + setUp(() { + exits = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'SystemNavigator.pop') exits.add(call); + return null; + }, + ); + router = GoRouter( + routes: [ + ShellRoute( + builder: (_, _, child) => MobileExitGuard(child: child), + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const SizedBox.expand(), + routes: [ + GoRoute(path: 'details', builder: (_, _) => const SizedBox.expand()), + for (final path in ['extensions', 'settings']) + GoRoute(path: path, builder: (_, _) => const SizedBox.expand()), + ], + ), + ], + ), + ], + ); + }); + + tearDown(() { + router.dispose(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + + Future mount(WidgetTester tester) async { + await tester.pumpWidget( + shad.ShadcnApp.router(theme: AppTheme.dark(), materialTheme: AppTheme.materialDark(), routerConfig: router), + ); + await tester.pumpAndSettle(); + } + + testWidgets('root back shows bottom prompt, second back exits', (tester) async { + await mount(tester); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(exits, isEmpty); + expect(find.byType(AppToastContent), findsOneWidget); + expect(tester.getCenter(find.byType(AppToastContent)).dx, 400); + expect(tester.getBottomLeft(find.byType(AppToastContent)).dy, 576); + await tester.pump(const Duration(milliseconds: 1900)); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(exits, hasLength(1)); + expect(find.byType(AppToastContent), findsNothing); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets('expired prompt requires a fresh pair of back actions', (tester) async { + await mount(tester); + await tester.binding.handlePopRoute(); + await tester.pump(); + await tester.pump(AppExitConfirmationController.confirmationWindow); + expect(find.byType(AppToastContent), findsNothing); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(exits, isEmpty); + expect(find.byType(AppToastContent), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + + testWidgets('nested page pops without prompting or exiting', (tester) async { + await mount(tester); + router.push('/details'); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.path, '/'); + expect(find.byType(AppToastContent), findsNothing); + expect(exits, isEmpty); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + testWidgets('backgrounding clears the exit confirmation', (tester) async { + await mount(tester); + await tester.binding.handlePopRoute(); + await tester.pump(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(find.byType(AppToastContent), findsNothing); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(exits, isEmpty); + expect(find.byType(AppToastContent), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + for (final path in ['/extensions', '/settings']) { + testWidgets('$path returns to tasks before confirming exit', (tester) async { + await mount(tester); + router.go(path); + await tester.pumpAndSettle(); + expect(router.canPop(), isTrue); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.path, '/'); + expect(find.byType(AppToastContent), findsNothing); + expect(exits, isEmpty); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.byType(AppToastContent), findsOneWidget); + expect(exits, isEmpty); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(exits, hasLength(1)); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + } + testWidgets( + 'dialog back dismisses the dialog before exit confirmation', + (tester) async { + await mount(tester); + final context = router.routerDelegate.navigatorKey.currentContext!; + showGeneralDialog( + context: context, + pageBuilder: (_, _, _) => const Center(child: Text('Dialog content')), + ); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('Dialog content'), findsNothing); + expect(find.byType(AppToastContent), findsNothing); + expect(exits, isEmpty); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + testWidgets('bottom prompt stays above the Android safe area', (tester) async { + tester.view.padding = const FakeViewPadding(bottom: 48); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPadding); + addTearDown(tester.view.resetDevicePixelRatio); + await mount(tester); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(tester.getBottomLeft(find.byType(AppToastContent)).dy, tester.view.physicalSize.height - 48 - 24); + await tester.pumpWidget(const SizedBox()); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); +} diff --git a/ui/flutter/test/app_deep_link_controller_test.dart b/ui/flutter/test/app_deep_link_controller_test.dart index 388bc53f..ea24bbda 100644 --- a/ui/flutter/test/app_deep_link_controller_test.dart +++ b/ui/flutter/test/app_deep_link_controller_test.dart @@ -1,8 +1,58 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:gopeed/features/extensions/application/pending_extension_install.dart'; + +import 'package:app_links_platform_interface/app_links_platform_interface.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/model/downloader_config.dart'; +import 'package:gopeed/app/application/app_runtime_controller.dart'; import 'package:gopeed/app/application/app_deep_link_controller.dart'; +import 'package:gopeed/core/common/api_server_state.dart'; +import 'package:gopeed/core/common/start_config.dart'; import 'package:share_handler/share_handler.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('receives initial and subsequent scheme extension URLs with UTF-8 params', () async { + final originalPlatform = AppLinksPlatform.instance; + final initialUrl = 'https://github.com/author/扩展'; + Uri link(String url) => Uri.parse('gopeed:///extension').replace( + queryParameters: { + 'params': base64Encode(utf8.encode(jsonEncode({'url': url}))), + }, + ); + final platform = _FakeAppLinksPlatform(initialLink: link(initialUrl)); + AppLinksPlatform.instance = platform; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('window_manager'), + (_) async => false, + ); + final container = ProviderContainer( + overrides: [appRuntimeControllerProvider.overrideWith(_FakeRuntimeController.new)], + ); + try { + await container.read(appDeepLinkControllerProvider.future); + expect(container.read(pendingExtensionInstallProvider)?.url, initialUrl); + container.read(pendingExtensionInstallProvider.notifier).clear(); + platform.emit(link('https://github.com/author/second')); + await Future.delayed(Duration.zero); + expect(container.read(pendingExtensionInstallProvider)?.url, 'https://github.com/author/second'); + } finally { + container.dispose(); + await Future.delayed(Duration.zero); + AppLinksPlatform.instance = originalPlatform; + await platform.close(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('window_manager'), + null, + ); + } + }); + test('keeps the established path-style Gopeed deep-link routes', () { expect(gopeedDeepLinkRoute(Uri.parse('gopeed:///create')), '/create'); expect(gopeedDeepLinkRoute(Uri.parse('gopeed:///extension')), '/extension'); @@ -27,4 +77,75 @@ void main() { expect(sharedMediaUri(media), Uri.parse('magnet:?xt=urn:btih:example')); }); + + test('keeps listening for browser takeover links after runtime settings change', () async { + final originalPlatform = AppLinksPlatform.instance; + final appLinksPlatform = _FakeAppLinksPlatform(); + AppLinksPlatform.instance = appLinksPlatform; + final container = ProviderContainer( + overrides: [appRuntimeControllerProvider.overrideWith(_FakeRuntimeController.new)], + ); + + try { + await container.read(appDeepLinkControllerProvider.future); + expect(appLinksPlatform.hasListener, isTrue); + + container.read(appRuntimeControllerProvider.notifier).replaceDownloaderConfig(DownloaderConfig()); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(appLinksPlatform.hasListener, isTrue); + } finally { + container.dispose(); + await Future.delayed(Duration.zero); + AppLinksPlatform.instance = originalPlatform; + await appLinksPlatform.close(); + } + }); +} + +class _FakeAppLinksPlatform extends AppLinksPlatform { + _FakeAppLinksPlatform({this.initialLink}); + + final Uri? initialLink; + void emit(Uri uri) => _links.add(uri); + final _links = StreamController.broadcast(); + + bool get hasListener => _links.hasListener; + + @override + Future getInitialLink() async => initialLink; + + @override + Stream get uriLinkStream => _links.stream; + + Future close() => _links.close(); +} + +class _FakeRuntimeController extends AppRuntimeController { + @override + Future build() async { + final config = StartConfig() + ..network = 'tcp' + ..address = '127.0.0.1:9999' + ..apiEnable = true + ..apiToken = '' + ..storage = 'bolt' + ..storageDir = '' + ..refreshInterval = 0; + return AppRuntimeState( + startConfig: config, + apiServerState: const ApiServerState( + enabled: true, + mcpEnabled: false, + running: true, + network: 'tcp', + address: '127.0.0.1:9999', + runningPort: 9999, + pendingApply: false, + lastError: '', + ), + downloaderConfig: DownloaderConfig(), + ); + } } diff --git a/ui/flutter/test/core/common/libgopeed_channel_test.dart b/ui/flutter/test/core/common/libgopeed_channel_test.dart new file mode 100644 index 00000000..a3b44b8e --- /dev/null +++ b/ui/flutter/test/core/common/libgopeed_channel_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/core/common/libgopeed_channel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('gopeed.com/libgopeed'); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + }); + + test('mobile invokes carry unique request IDs for async callbacks', () async { + final requestIDs = []; + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'invoke'); + final arguments = (call.arguments as Map).cast(); + requestIDs.add(arguments['requestID']! as int); + return '{"code":0}'; + }); + + final bridge = LibgopeedChannel(); + await Future.wait([bridge.invoke('GET', '/api/v1/info'), bridge.invoke('GET', '/api/v1/config')]); + + expect(requestIDs, [0, 1]); + }); +} diff --git a/ui/flutter/test/core/utils/compact_count_formatter_test.dart b/ui/flutter/test/core/utils/compact_count_formatter_test.dart new file mode 100644 index 00000000..0cf064ca --- /dev/null +++ b/ui/flutter/test/core/utils/compact_count_formatter_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/core/utils/compact_count_formatter.dart'; + +void main() { + test('keeps small counts exact and compacts larger counts to a bounded label', () { + expect(CompactCountFormatter.format(-1), '0'); + expect(CompactCountFormatter.format(0), '0'); + expect(CompactCountFormatter.format(9999), '9999'); + expect(CompactCountFormatter.format(10000), '1w'); + expect(CompactCountFormatter.format(12345), '1.2w'); + expect(CompactCountFormatter.format(99999), '9.9w'); + expect(CompactCountFormatter.format(123456), '12w'); + expect(CompactCountFormatter.format(1000000), '1m'); + expect(CompactCountFormatter.format(1234567), '1.2m'); + expect(CompactCountFormatter.format(10000000), '10m'); + expect(CompactCountFormatter.format(12345678), '12m'); + expect(CompactCountFormatter.format(100000000), '100m'); + expect(CompactCountFormatter.format(123456789), '123m'); + expect(CompactCountFormatter.format(1000000000), '1b'); + expect(CompactCountFormatter.format(1234567890), '1.2b'); + expect(CompactCountFormatter.format(12345678901), '12b'); + expect(CompactCountFormatter.format(1234567890123), '1.2t'); + expect(CompactCountFormatter.format(1234567890123456), '1.2q'); + expect(CompactCountFormatter.format(9223372036854775807), '9.2qi'); + }); +} diff --git a/ui/flutter/test/core/window/app_window_payload_test.dart b/ui/flutter/test/core/window/app_window_payload_test.dart index 9b71c2b3..d4bdd090 100644 --- a/ui/flutter/test/core/window/app_window_payload_test.dart +++ b/ui/flutter/test/core/window/app_window_payload_test.dart @@ -9,9 +9,9 @@ void main() { test('create-task child window has a localized native title', () { expect( AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('en')), - 'Create Task - Gopeed', + 'Gopeed-Create Task', ); - expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), '创建任务 - Gopeed'); + expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), 'Gopeed-创建任务'); }); test('create-task payload only carries initial task data', () { diff --git a/ui/flutter/test/features/extensions/presentation/widgets/extension_setting_field_test.dart b/ui/flutter/test/features/extensions/presentation/widgets/extension_setting_field_test.dart new file mode 100644 index 00000000..8db50b74 --- /dev/null +++ b/ui/flutter/test/features/extensions/presentation/widgets/extension_setting_field_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:gopeed/api/model/extension.dart'; +import 'package:gopeed/features/extensions/presentation/widgets/extension_setting_field.dart'; +import 'package:gopeed/shared/widgets/app_text_field.dart'; + +void main() { + Future pumpField(WidgetTester tester, Setting setting, TextEditingController controller) async { + await tester.pumpWidget( + ShadcnApp( + home: Scaffold( + child: Center( + child: SizedBox( + width: 280, + child: ExtensionSettingField(setting: setting, controller: controller), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Setting setting(SettingType type) => + Setting(name: 'test', title: 'Test setting', description: 'Description', required: false, type: type); + + testWidgets('boolean switch loads and updates the saved value', (tester) async { + final controller = TextEditingController(text: 'true'); + addTearDown(controller.dispose); + await pumpField(tester, setting(SettingType.boolean), controller); + expect(find.byType(AppTextField), findsNothing); + expect(tester.widget(find.byType(Switch)).value, isTrue); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + expect(controller.text, 'false'); + expect(tester.widget(find.byType(Switch)).value, isFalse); + }); + + for (final type in [SettingType.string, SettingType.number]) { + testWidgets( + '$type options show labels and save selected values', + (tester) async { + final field = setting(type)..options = [Option(label: 'First', value: 1), Option(label: 'Second', value: 2)]; + final controller = TextEditingController(text: '1'); + addTearDown(controller.dispose); + await pumpField(tester, field, controller); + expect(find.byType(AppTextField), findsNothing); + expect(find.text('First'), findsOneWidget); + await tester.tap(find.byType(Select)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.text('Second')); + await tester.pumpAndSettle(); + expect(controller.text, '2'); + expect(find.text('Second'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.macOS}), + ); + } + + testWidgets('number field accepts decimals and rejects nonnumeric input', (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + await pumpField(tester, setting(SettingType.number), controller); + await tester.enterText(find.byType(AppTextField), '-12.5'); + expect(controller.text, '-12.5'); + await tester.enterText(find.byType(AppTextField), 'invalid'); + expect(controller.text, '-12.5'); + }); + + testWidgets('string with empty options remains a text field', (tester) async { + final controller = TextEditingController(text: 'original'); + addTearDown(controller.dispose); + await pumpField(tester, setting(SettingType.string)..options = [], controller); + await tester.enterText(find.byType(AppTextField), 'updated'); + expect(controller.text, 'updated'); + }); +} diff --git a/ui/flutter/test/features/tasks/domain/task_record_test.dart b/ui/flutter/test/features/tasks/domain/task_record_test.dart index a1abb037..17a8e4be 100644 --- a/ui/flutter/test/features/tasks/domain/task_record_test.dart +++ b/ui/flutter/test/features/tasks/domain/task_record_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:gopeed/api/model/task.dart' as api; +import 'package:gopeed/core/icons/gopeed_icons.dart'; import 'package:gopeed/features/tasks/domain/task_record.dart'; void main() { @@ -41,4 +42,19 @@ void main() { expect(record.files.length, 3); expect(record.totalBytes, 60); }); + + test('file names preserve hash and question mark characters for type detection', () { + expect( + taskFileTypeIcon('Slow English Podcast #2 _ Eating, Talking, and Learning Natural English.mp4'), + GopeedIcons.fileVideo, + ); + expect(taskFileTypeIcon('What is this? #2.MP4'), GopeedIcons.fileVideo); + expect(taskFileTypeIcon('Episode #2.mp3'), GopeedIcons.fileAudio); + }); + + test('URL fallback ignores query and fragment when detecting file type', () { + expect(taskFileTypeIcon('https://example.com/video.mp4?token=abc#part'), GopeedIcons.fileVideo); + expect(taskFileTypeIcon('https://example.com/download?name=video.mp4'), GopeedIcons.file); + expect(taskFileTypeIcon('Playlist #2.mp4', isFolder: true), GopeedIcons.folder); + }); } diff --git a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart index dc45d9b9..c9114bcc 100644 --- a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart +++ b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart @@ -1,5 +1,8 @@ import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/widgets.dart' as flutter show Row; +import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; import 'package:flutter/material.dart' show Icons; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -8,19 +11,197 @@ import 'package:gopeed/api/model/create_task.dart'; import 'package:gopeed/api/model/downloader_config.dart'; import 'package:gopeed/api/model/options.dart'; import 'package:gopeed/api/model/request.dart'; +import 'package:gopeed/api/model/resolve_result.dart'; +import 'package:gopeed/api/model/resource.dart'; import 'package:gopeed/core/capabilities/app_capabilities.dart'; +import 'package:gopeed/core/capabilities/app_navigation_capability.dart'; import 'package:gopeed/core/capabilities/capability_rpc.dart'; import 'package:gopeed/core/capabilities/gopeed_capability.dart'; import 'package:gopeed/core/capabilities/storage_capability.dart'; import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart'; +import 'package:gopeed/features/tasks/application/pending_create_task.dart'; import 'package:gopeed/shared/services/download_directory_picker.dart'; import 'package:gopeed/shared/theme/app_component_themes.dart'; import 'package:gopeed/shared/theme/app_design_tokens.dart'; import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_loading_button.dart'; import 'package:gopeed/shared/widgets/app_tooltip.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; void main() { + for (final navigationFails in [false, true]) { + testWidgets('child creation selects downloads before closing (navigation fails: $navigationFails)', (tester) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final events = []; + const channel = MethodChannel('window_manager'); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + events.add(call.method); + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => config) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()) + ..bind(GopeedMethods.createTask, (_) { + events.add('created'); + return 'task-id'; + }) + ..bind(NavigationMethods.showDownloadingTasks, (_) { + events.add('downloads'); + if (navigationFails) throw StateError('Navigation unavailable'); + return const RpcUnit(); + }); + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: CreateTaskWindowPage( + windowController: WindowController.fromWindowId('child'), + initialTask: CreateTask(req: Request(url: 'https://example.com/file.apk')), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Confirm')); + // The mocked native close deliberately leaves the widget mounted. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(events, ['created', 'downloads', 'close']); + await tester.pumpWidget(const SizedBox()); + }); + } + + testWidgets('repeated external tasks update the open create route and replace request metadata', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + tester.view.physicalSize = const Size(700, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + CreateTask? submitted; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) + ..bind(GopeedMethods.createTask, (task) { + submitted = task; + return 'created-id'; + }) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final container = ProviderContainer( + overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))], + ); + addTearDown(container.dispose); + final pending = container.read(pendingCreateTaskProvider.notifier); + pending.set( + CreateTask( + req: Request( + url: 'https://example.com/first.zip', + rawUrl: 'https://example.com/first', + labels: {'source': 'first'}, + extra: ReqExtraHttp(method: 'POST', body: 'old-body', header: {'Cookie': 'old-cookie'}).toJson(), + proxy: RequestProxy(mode: RequestProxyMode.custom, host: 'localhost:8080'), + skipVerifyCert: true, + ), + ), + ); + final router = GoRouter( + initialLocation: '/create', + routes: [ + GoRoute(path: '/', builder: (_, _) => const SizedBox()), + GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: shad.ShadcnApp.router( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + routerConfig: router, + builder: (_, child) => AppComponentThemes(child: child!), + ), + ), + ); + await tester.pumpAndSettle(); + expect(_fieldText(tester, 'create-task-url-input'), 'https://example.com/first.zip'); + expect(container.read(pendingCreateTaskProvider), isNull); + final pageState = tester.state(find.byType(CreateTaskWindowPage)); + + for (final url in [ + 'https://example.com/second.zip', + 'https://example.com/third.zip', + 'https://example.com/third.zip', + ]) { + pending.set(CreateTask(req: Request(url: url))); + router.go('/create'); + await tester.pumpAndSettle(); + expect(tester.state(find.byType(CreateTaskWindowPage)), same(pageState)); + expect(_fieldText(tester, 'create-task-url-input'), url); + expect(container.read(pendingCreateTaskProvider), isNull); + } + await tester.tap(find.byKey(const ValueKey('create-task-direct-download-toggle'))); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + expect(submitted?.req?.url, 'https://example.com/third.zip'); + expect(submitted?.req?.rawUrl, isNull); + expect(submitted?.req?.labels, isNull); + expect(submitted?.req?.extra, isNull); + expect(submitted?.req?.proxy?.mode, RequestProxyMode.follow); + expect(submitted?.req?.skipVerifyCert, isFalse); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('mobile create task page stays within the system safe areas', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + tester.view.physicalSize = const Size(700, 760); + tester.view.devicePixelRatio = 1; + tester.view.padding = const FakeViewPadding(top: 32, bottom: 24); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPadding); + + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + try { + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes(child: const CreateTaskWindowPage()), + ), + ), + ); + await tester.pumpAndSettle(); + + final contentPadding = tester.widget(find.byKey(const ValueKey('create-task-safe-content'))); + expect((contentPadding.padding as EdgeInsets).top, 32); + expect( + tester.getBottomRight(find.byKey(const ValueKey('create-task-confirm-button'))).dy, + lessThanOrEqualTo(tester.view.physicalSize.height / tester.view.devicePixelRatio - 24), + ); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + testWidgets('browser extension request parameters populate the form and survive submission', ( WidgetTester tester, ) async { @@ -121,7 +302,10 @@ void main() { final connectionsInput = find.byKey(const ValueKey('create-task-connections-input')); expect(connectionsInput, findsOneWidget); - expect(tester.getSize(connectionsInput).width, AppDesignTokens.settingsNumberControlWidth); + expect( + tester.getSize(connectionsInput).width, + tester.getSize(find.byKey(const ValueKey('create-task-rename-input'))).width, + ); final connectionsField = tester.widget(connectionsInput); expect(connectionsField.controller!.text, '12'); expect(connectionsField.features.single, isA()); @@ -280,6 +464,58 @@ void main() { expect(submitted?.opts?.asDefaultPath, isTrue); }); + testWidgets('resolved task keeps rename empty and uses equal-width single-line actions', (WidgetTester tester) async { + tester.view.physicalSize = const Size(700, 700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) + ..bind( + GopeedMethods.resolve, + (_) => ResolveResult( + id: 'resolved-id', + res: Resource( + name: 'server-generated-name', + files: [FileInfo(name: 'video.mp4', size: 1024)], + ), + ), + ) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes(child: const CreateTaskWindowPage()), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/video.mp4'); + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(_fieldText(tester, 'create-task-rename-input'), isEmpty); + final createButton = tester.widget(find.byKey(const ValueKey('resolve-create-button'))); + expect(createButton.child, isA()); + expect((createButton.child as SizedBox).width, 68); + expect( + find.descendant(of: find.byKey(const ValueKey('resolve-create-button')), matching: find.text('Create')), + findsOneWidget, + ); + expect( + tester.getSize(find.byKey(const ValueKey('resolve-cancel-button'))), + tester.getSize(find.byKey(const ValueKey('resolve-create-button'))), + ); + expect(tester.takeException(), isNull); + }); + testWidgets('create history supports filtering, deleting one entry, and clearing all entries', ( WidgetTester tester, ) async { @@ -324,6 +560,7 @@ void main() { await tester.tap(find.byIcon(Icons.history)); await tester.pumpAndSettle(); + expect(find.text('History Links'), findsOneWidget); expect( tester.getSize(find.byKey(const ValueKey('create-history-title-bar'))).width, tester.getSize(find.byKey(const ValueKey('create-history-content'))).width, diff --git a/ui/flutter/test/features/tasks/presentation/history_search_field_test.dart b/ui/flutter/test/features/tasks/presentation/history_search_field_test.dart new file mode 100644 index 00000000..0bf81cc1 --- /dev/null +++ b/ui/flutter/test/features/tasks/presentation/history_search_field_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/history_search_field.dart'; +import 'package:gopeed/l10n/l10n.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + for (final platform in [TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.macOS]) { + for (final scale in [1.0, 1.5, 2.0]) { + testWidgets('history hint and input line align on $platform at scale $scale', (tester) async { + debugDefaultTargetPlatformOverride = platform; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final controller = TextEditingController(); + addTearDown(controller.dispose); + String? query; + for (final width in [280.0, 360.0, 600.0]) { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: AppComponentThemes( + child: Builder( + builder: (context) => Localizations.override( + context: context, + locale: const Locale('zh'), + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: Center( + child: SizedBox( + width: width, + child: HistorySearchField(controller: controller, onChanged: (value) => query = value), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final editable = tester.state(find.byType(EditableText)).renderEditable; + final hint = tester.renderObject(find.text('搜索历史记录…')); + expect(editable.preferredLineHeight, lessThanOrEqualTo(editable.size.height)); + expect(hint.size.height, closeTo(editable.preferredLineHeight, 0.1)); + expect(hint.localToGlobal(Offset.zero).dy, closeTo(editable.localToGlobal(Offset.zero).dy, 0.1)); + expect(tester.takeException(), isNull); + } + await tester.enterText(find.byType(EditableText), 'example'); + expect(query, 'example'); + debugDefaultTargetPlatformOverride = null; + }); + } + } +} diff --git a/ui/flutter/test/features/tasks/presentation/resolve_task_actions_test.dart b/ui/flutter/test/features/tasks/presentation/resolve_task_actions_test.dart new file mode 100644 index 00000000..8095bdf0 --- /dev/null +++ b/ui/flutter/test/features/tasks/presentation/resolve_task_actions_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/create_task_action_buttons.dart'; +import 'package:gopeed/l10n/l10n.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_loading_button.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + for (final width in [390.0, 1024.0]) { + for (final language in ['en', 'zh', 'de']) { + testWidgets('resolve actions stay equal at $width in $language, including loading', (tester) async { + tester.view.physicalSize = Size(width, 760); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var submitting = false; + var cancelled = false; + await tester.pumpWidget( + shad.ShadcnApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Builder( + builder: (context) => Localizations.override( + context: context, + locale: Locale(language), + delegates: const [AppLocalizations.delegate], + child: AppComponentThemes( + child: Center( + child: StatefulBuilder( + builder: (context, setState) => shad.AlertDialog( + padding: const EdgeInsets.all(18), + content: SizedBox(width: width < 760 ? width - 32 : 720, height: 100), + actions: [ + Flexible( + child: CreateTaskActionButtons( + submitting: submitting, + onCancel: () => cancelled = true, + onSubmit: () => setState(() => submitting = true), + cancelLabel: context.l10n.cancel, + submitLabel: context.l10n.createAction, + cancelButtonKey: const ValueKey('resolve-cancel-button'), + submitButtonKey: const ValueKey('resolve-create-button'), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final cancel = find.byKey(const ValueKey('resolve-cancel-button')); + final create = find.byKey(const ValueKey('resolve-create-button')); + final cancelRect = tester.getRect(cancel); + final createRect = tester.getRect(create); + expect(cancelRect.size, createRect.size); + expect(cancelRect.center.dy, createRect.center.dy); + expect(createRect.left - cancelRect.right, 12); + expect((tester.widget(cancel).child as SizedBox).width, 68); + expect((tester.widget(create).child as SizedBox).width, 68); + expect(cancelRect.left, greaterThanOrEqualTo(0)); + expect(createRect.right, lessThanOrEqualTo(width)); + expect(find.text(appLocalizationsFor(language).createAction), findsOneWidget); + await tester.tap(cancel); + expect(cancelled, isTrue); + await tester.tap(create); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.widget(create).loading, isTrue); + expect(tester.widget(cancel).onPressed, isNull); + expect(tester.getRect(cancel), cancelRect); + expect(tester.getRect(create), createRect); + expect(tester.takeException(), isNull); + }); + } + } +} diff --git a/ui/flutter/test/shared/theme/app_component_themes_test.dart b/ui/flutter/test/shared/theme/app_component_themes_test.dart index 9080b1e2..b78b0235 100644 --- a/ui/flutter/test/shared/theme/app_component_themes_test.dart +++ b/ui/flutter/test/shared/theme/app_component_themes_test.dart @@ -7,6 +7,30 @@ import 'package:gopeed/shared/theme/app_theme.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; void main() { + testWidgets('provides the standard text field appearance', (tester) async { + late shad.TextFieldTheme textFieldTheme; + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Builder( + builder: (context) { + textFieldTheme = shad.ComponentTheme.of(context); + return const SizedBox(); + }, + ), + ), + ), + ); + + expect(textFieldTheme.filled, isTrue); + expect(textFieldTheme.border, Border.all(color: AppPalette.light.border)); + expect(textFieldTheme.borderRadius, BorderRadius.circular(AppDesignTokens.controlRadius)); + expect(textFieldTheme.padding, AppDesignTokens.textFieldPadding); + }); + testWidgets('provides the standard checkbox metrics', (tester) async { late shad.CheckboxTheme checkboxTheme; diff --git a/ui/flutter/test/shared/widgets/app_loading_button_test.dart b/ui/flutter/test/shared/widgets/app_loading_button_test.dart index 1726cfd6..8d3f1ded 100644 --- a/ui/flutter/test/shared/widgets/app_loading_button_test.dart +++ b/ui/flutter/test/shared/widgets/app_loading_button_test.dart @@ -134,7 +134,10 @@ void main() { of: find.byKey(const ValueKey('text-only-loading-button')), matching: find.byType(shad.CircularProgressIndicator), ); - expect(find.text('Login'), findsNothing); + final hiddenLabel = find.text('Login'); + expect(hiddenLabel, findsOneWidget); + expect(tester.widget(find.ancestor(of: hiddenLabel, matching: find.byType(Opacity))).opacity, 0); + expect(find.ancestor(of: hiddenLabel, matching: find.byType(ExcludeSemantics)), findsOneWidget); expect(loader, findsOneWidget); expect(tester.getRect(loader).center, bounds.center); }); diff --git a/ui/flutter/test/shared/widgets/app_path_picker_field_test.dart b/ui/flutter/test/shared/widgets/app_path_picker_field_test.dart new file mode 100644 index 00000000..78321a1e --- /dev/null +++ b/ui/flutter/test/shared/widgets/app_path_picker_field_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_path_picker_field.dart'; +import 'package:gopeed/shared/widgets/app_text_field.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + testWidgets('file paths remain editable and Web hides native file selection', (tester) async { + final controller = TextEditingController(text: '/scripts/old.sh'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Center( + child: AppPathPickerField.file( + controller: controller, + desktopWidth: 320, + pickerKey: const ValueKey('file-picker'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('file-picker')), kIsWeb ? findsNothing : findsOneWidget); + expect(tester.widget(find.byType(AppTextField)).readOnly, isFalse); + await tester.enterText(find.byType(EditableText), '/scripts/custom.sh'); + await tester.pump(); + expect(controller.text, '/scripts/custom.sh'); + expect(tester.takeException(), isNull); + }); +} diff --git a/ui/flutter/test/shared/widgets/app_text_field_test.dart b/ui/flutter/test/shared/widgets/app_text_field_test.dart new file mode 100644 index 00000000..cd7c35a9 --- /dev/null +++ b/ui/flutter/test/shared/widgets/app_text_field_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' as material show AdaptiveTextSelectionToolbar; +import 'package:flutter/services.dart' show BrowserContextMenu; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_text_field.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + testWidgets('Android long press uses Flutter adaptive text selection toolbar', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final controller = TextEditingController(text: 'https://example.com/download'); + addTearDown(controller.dispose); + + try { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Center( + child: SizedBox(width: 320, child: AppTextField(controller: controller)), + ), + ), + ), + ); + + await tester.longPress(find.byType(AppTextField)); + await tester.pumpAndSettle(); + + expect(find.byType(material.AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.byType(shad.MobileEditableTextContextMenu), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, skip: kIsWeb); + + testWidgets('desktop right click uses Flutter adaptive text selection toolbar', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + final controller = TextEditingController(text: 'https://example.com/download'); + addTearDown(controller.dispose); + + try { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Center( + child: SizedBox(width: 320, child: AppTextField(controller: controller)), + ), + ), + ), + ); + + final field = find.byType(AppTextField); + final gesture = await tester.startGesture( + tester.getCenter(field), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(material.AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.byType(shad.DesktopEditableTextContextMenu), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, skip: kIsWeb); + + testWidgets('Web right click keeps the browser menu and creates no Flutter overlay', (tester) async { + final controller = TextEditingController(text: 'https://example.com/download'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + shad.ShadcnApp( + disableBrowserContextMenu: false, + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: Center( + child: SizedBox(width: 320, child: AppTextField(controller: controller)), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(BrowserContextMenu.enabled, isTrue); + final gesture = await tester.startGesture( + tester.getCenter(find.byType(AppTextField)), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byType(material.AdaptiveTextSelectionToolbar), findsNothing); + expect(find.byType(shad.DesktopEditableTextContextMenu), findsNothing); + expect(find.byType(shad.MobileEditableTextContextMenu), findsNothing); + }, skip: !kIsWeb); + + test('AppTextField defaults to the shared context menu policy', () { + expect(const AppTextField().contextMenuBuilder, same(appTextFieldContextMenuBuilder)); + }); +} diff --git a/ui/flutter/test/updater_test.dart b/ui/flutter/test/updater_test.dart index 6ec81862..6ea25fad 100644 --- a/ui/flutter/test/updater_test.dart +++ b/ui/flutter/test/updater_test.dart @@ -1,7 +1,9 @@ +import 'package:flutter/material.dart' show Icons; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gopeed/api/model/downloader_config.dart'; import 'package:gopeed/features/settings/presentation/widgets/app_update_dialog.dart'; +import 'package:gopeed/features/settings/presentation/widgets/app_update_notes_viewport.dart'; import 'package:gopeed/shared/theme/app_theme.dart'; import 'package:gopeed/util/arch/arch.dart'; import 'package:gopeed/util/github_mirror.dart'; @@ -193,10 +195,47 @@ void main() { expect(find.text('下载更快'), findsNothing); expect(find.text('Later'), findsOneWidget); expect(find.text('Update Now'), findsOneWidget); + expect(find.text("What's New"), findsNothing); + final versionText = tester.widget(find.text('2.0.0-beta.1 → 2.0.0-beta.2')); + expect(versionText.style?.decoration, TextDecoration.none); + expect(versionText.style?.inherit, isFalse); + expect(find.byIcon(Icons.system_update_alt_outlined), findsNothing); final dialog = tester.getRect(find.byKey(const ValueKey('app-update-dialog'))); expect(dialog.width, lessThan(320)); expect(tester.getCenter(find.byKey(const ValueKey('app-update-heading'))).dx, closeTo(dialog.center.dx, 0.01)); expect(tester.getCenter(find.byKey(const ValueKey('app-update-actions'))).dx, closeTo(dialog.center.dx, 0.01)); expect(tester.takeException(), isNull); }); + + testWidgets('release notes keep the last line above the fade when scrolled to the end', (tester) async { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.dark(), + materialTheme: AppTheme.materialDark(), + home: Center( + child: SizedBox( + width: 280, + child: AppUpdateNotesViewport( + child: Column(children: [for (var i = 0; i < 30; i++) Text('Release note $i')]), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final scrollbar = tester.widget(find.byType(shad.Scrollbar)); + expect(scrollbar.thumbVisibility, isTrue); + final controller = scrollbar.controller!; + expect(controller.position.maxScrollExtent, greaterThan(0)); + controller.jumpTo(controller.position.maxScrollExtent); + await tester.pumpAndSettle(); + final viewport = tester.getRect(find.byType(AppUpdateNotesViewport)); + final lastLine = tester.getRect(find.text('Release note 29')); + expect(lastLine.bottom, lessThanOrEqualTo(viewport.bottom - 24)); + expect(lastLine.top, greaterThan(viewport.top)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); } diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 860f9138..3f0732e3 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -1,8 +1,9 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; -import 'package:flutter/material.dart' show Icons, Scrollbar; +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, debugDefaultTargetPlatformOverride, kIsWeb; +import 'package:flutter/material.dart' show Icons, Scrollbar, SelectionArea; import 'package:flutter/gestures.dart' show PointerDeviceKind, kDoubleTapMinTime, kSecondaryMouseButton; import 'package:flutter/rendering.dart' show RenderParagraph; import 'package:flutter/services.dart' show MethodChannel, SystemChannels; @@ -12,13 +13,25 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import 'package:window_manager/window_manager.dart'; +import 'package:gopeed/features/home/presentation/pages/home_page.dart'; import 'package:gopeed/app/app.dart'; +import 'package:gopeed/app/router/app_router.dart'; +import 'package:gopeed/core/capabilities/capability_rpc.dart'; +import 'package:gopeed/core/capabilities/gopeed_capability.dart'; +import 'package:gopeed/core/capabilities/storage_capability.dart'; +import 'package:gopeed/api/model/resolve_result.dart'; +import 'package:gopeed/features/tasks/application/pending_create_task.dart'; +import 'package:gopeed/features/tasks/application/task_list_navigation.dart'; +import 'package:gopeed/core/capabilities/app_capabilities.dart'; +import 'package:gopeed/core/capabilities/app_navigation_capability.dart'; import 'package:gopeed/app/application/app_appearance_controller.dart'; import 'package:gopeed/app/application/app_deep_link_controller.dart'; import 'package:gopeed/app/application/app_notification_controller.dart'; import 'package:gopeed/app/application/app_platform_controller.dart'; import 'package:gopeed/app/application/app_runtime_controller.dart'; import 'package:gopeed/api/model/create_task.dart'; +import 'package:gopeed/api/model/install_extension.dart'; +import 'package:gopeed/features/extensions/application/pending_extension_install.dart'; import 'package:gopeed/api/model/downloader_config.dart'; import 'package:gopeed/api/model/extension.dart' as api_extension; import 'package:gopeed/api/model/meta.dart'; @@ -81,6 +94,7 @@ import 'package:gopeed/shared/widgets/app_copy_icon_button.dart'; import 'package:gopeed/shared/widgets/app_http_headers_editor.dart'; import 'package:gopeed/shared/widgets/app_path_picker_field.dart'; import 'package:gopeed/shared/widgets/app_primary_button.dart'; +import 'package:gopeed/shared/widgets/app_text_field.dart'; import 'package:gopeed/shared/widgets/app_tooltip.dart'; import 'package:gopeed/shared/widgets/app_toast.dart'; import 'package:gopeed/shared/widgets/gopeed_app_mark.dart'; @@ -92,6 +106,117 @@ import 'package:gopeed/util/updater.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized().platformDispatcher.localeTestValue = const Locale('en'); + for (final direct in [true, false]) { + testWidgets( + 'mobile creation selects downloading after success (direct: $direct)', + (tester) async { + await _setTestSize(tester, const Size(700, 900)); + var created = 0; + final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = direct; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => config) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()) + ..bind( + GopeedMethods.resolve, + (_) => ResolveResult( + id: 'resolved', + res: Resource( + name: 'new.zip', + files: [FileInfo(name: 'new.zip', size: 10)], + ), + ), + ) + ..bind(GopeedMethods.createTask, (_) { + created++; + return 'new-task'; + }); + final container = ProviderContainer( + overrides: [ + appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))), + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + appDeepLinkControllerProvider.overrideWith(FakeDeepLinkController.new), + appNotificationControllerProvider.overrideWith(FakeNotificationController.new), + tasksControllerProvider.overrideWith(CompletedTasksController.new), + settingsControllerProvider.overrideWith(FakeSettingsController.new), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget(UncontrolledProviderScope(container: container, child: const GopeedApp())); + await tester.pumpAndSettle(); + await tester.tap(find.text('Completed 1')); + await tester.pumpAndSettle(); + expect(find.text('done.zip'), findsOneWidget); + final router = GoRouter.of(AppRouter.rootNavigatorKey.currentContext!); + void openCreate() { + container + .read(pendingCreateTaskProvider.notifier) + .set(CreateTask(req: Request(url: 'https://example.com/new.zip'))); + router.go('/create'); + } + + openCreate(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('create-task-cancel-button'))); + await tester.pumpAndSettle(); + expect(find.text('done.zip'), findsOneWidget); + expect(container.read(taskListNavigationProvider), 0); + expect(created, 0); + openCreate(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + if (!direct) { + expect(created, 0); + await tester.tap(find.byKey(const ValueKey('resolve-create-button'))); + await tester.pumpAndSettle(); + } + await tester.pumpAndSettle(); + expect(created, 1); + expect(router.routeInformationProvider.value.uri.path, '/'); + expect(container.read(taskListNavigationProvider), 1); + expect(find.text('done.zip'), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }, + variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}), + ); + } + + testWidgets('child success returns to downloading from completed and another page', (tester) async { + await _setTestSize(tester, const Size(1024, 768)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + tasksControllerProvider.overrideWith(CompletedTasksController.new), + settingsControllerProvider.overrideWith(FakeSettingsController.new), + ], + child: const GopeedApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Completed')); + await tester.pumpAndSettle(); + expect(find.text('done.zip'), findsOneWidget); + + // Exercise the same serialized operation dispatched by a child window. + await LocalAppCapabilities.instance.registry.invoke(NavigationMethods.showDownloadingTasks.name, {}); + await tester.pumpAndSettle(); + expect(find.text('done.zip'), findsNothing); + expect(find.text('Create Task'), findsOneWidget); + + GoRouter.of(AppRouter.rootNavigatorKey.currentContext!).go('/settings'); + await tester.pumpAndSettle(); + await LocalAppCapabilities.instance.capabilities.navigation.showDownloadingTasks(); + await tester.pumpAndSettle(); + expect(GoRouter.of(AppRouter.rootNavigatorKey.currentContext!).routeInformationProvider.value.uri.path, '/'); + expect(find.text('done.zip'), findsNothing); + expect(find.text('Create Task'), findsOneWidget); + }); + test('Web MCP endpoint excludes page path, query, and hash route', () { expect( webMcpEndpoint(Uri.parse('https://gopeed.example:8443/app?source=desktop#/settings/advanced')), @@ -371,10 +496,11 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); - await tester.enterText(find.byType(shad.TextField), 'not-a-task'); + await tester.enterText(find.byType(AppTextField), 'not-a-task'); await tester.pump(); expect(find.text('No matching tasks found'), findsOneWidget); + expect(find.byKey(const ValueKey('tasks-empty-create-button')), findsNothing); expect(find.text('No tasks in this list'), findsNothing); }); @@ -401,6 +527,20 @@ void main() { expect(find.text('Downloading 0'), findsOneWidget); expect(find.text('Completed 0'), findsOneWidget); expect(find.text('Failed 0'), findsOneWidget); + expect(find.text('Create Task'), findsOneWidget); + final headerCreate = find.byKey(const ValueKey('tasks-mobile-create-button')); + expect(tester.getSize(headerCreate).height, 34); + final batchButton = find.byKey(const ValueKey('tasks-mobile-batch-button')); + expect(tester.getRect(headerCreate).height, tester.getRect(batchButton).height); + expect(tester.getRect(headerCreate).top, tester.getRect(batchButton).top); + expect(tester.getRect(headerCreate).bottom, tester.getRect(batchButton).bottom); + expect(tester.getBottomLeft(headerCreate).dy, lessThan(tester.getTopLeft(find.text('Downloading 0')).dy)); + for (final size in [const Size(320, 568), const Size(768, 1024)]) { + tester.view.physicalSize = size; + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.byKey(const ValueKey('tasks-empty-create-button')), findsOneWidget); + } }); testWidgets('extensions grid adds columns from a minimum card width and keeps desktop toolbar aligned', ( @@ -413,7 +553,7 @@ void main() { child: shad.ShadcnApp( theme: AppTheme.light(), materialTheme: AppTheme.materialLight(), - home: const ExtensionsPage(), + home: const AppComponentThemes(child: ExtensionsPage()), ), ), ); @@ -433,6 +573,7 @@ void main() { final cardRect = tester.getRect(find.byKey(const ValueKey('extension-card-extension-0'))); final rightmostCardRect = tester.getRect(find.byKey(const ValueKey('extension-card-extension-2'))); final appMarkRect = tester.getRect(find.byKey(const ValueKey('primary-rail-app-mark'))); + expect(searchRect.height, closeTo(sortRect.height, 0.01)); expect(searchRect.width, 240); expect(searchRect.width, lessThan(cardRect.width)); expect(sortRect.left - searchRect.right, closeTo(10, 0.01)); @@ -451,6 +592,11 @@ void main() { closeTo(tester.getRect(find.byKey(const ValueKey('extension-card-extension-2'))).right, 0.01), ); + tester.view.physicalSize = const Size(1024, 768); + await tester.pumpAndSettle(); + expect(gridColumns(), 3); + expect(tester.getSize(find.byKey(const ValueKey('extension-card-extension-0'))).width, greaterThanOrEqualTo(290)); + tester.view.physicalSize = const Size(1028, 608); await tester.pumpAndSettle(); expect(gridColumns(), 3); @@ -465,6 +611,7 @@ void main() { expect(find.byType(PrimaryRail), findsOneWidget); final compactSearchRect = tester.getRect(find.byKey(const ValueKey('extension-search-field-container'))); expect(compactSearchRect.width, lessThan(240)); + expect(compactSearchRect.height, closeTo(sortRect.height, 0.01)); expect( tester.getRect(find.byKey(const ValueKey('extension-sort-control'))).center.dy, closeTo(compactSearchRect.center.dy, 0.01), @@ -482,6 +629,31 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('extension cards compact large counts without crowding their actions', (WidgetTester tester) async { + await _setTestSize(tester, const Size(722, 608)); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(CrowdedExtensionCardController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const AppComponentThemes(child: ExtensionsPage()), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('1.2w'), findsOneWidget); + expect(find.text('1.2b'), findsOneWidget); + final cardRect = tester.getRect(find.byKey(const ValueKey('extension-card-crowded-extension'))); + final statsRect = tester.getRect(find.byKey(const ValueKey('extension-card-stats-crowded-extension'))); + final actionsRect = tester.getRect(find.byKey(const ValueKey('extension-card-actions-crowded-extension'))); + expect(cardRect.width, closeTo(290, 0.01)); + expect(statsRect.right, lessThanOrEqualTo(actionsRect.left - 8)); + expect(actionsRect.right, lessThanOrEqualTo(cardRect.right - 14)); + expect(tester.takeException(), isNull); + }); + testWidgets('extension details reuse the desktop detail drawer', (WidgetTester tester) async { await _setTestSize(tester, const Size(1100, 900)); final controller = FakeExtensionsController(); @@ -592,6 +764,73 @@ void main() { expect(tester.takeException(), isNull); }); + for (final (width, details) in [(390.0, false), (1100.0, false), (1100.0, true)]) { + testWidgets('extension update confirms, shows progress and retries at $width details=$details', (tester) async { + await _setTestSize(tester, Size(width, 900)); + final controller = UpdateableExtensionsController(); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(() => controller)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + if (details) { + await tester.tap(find.byKey(const ValueKey('extension-card-extension-0'))); + await tester.pumpAndSettle(); + } + final entry = find.byKey(ValueKey(details ? 'extension-details-update' : 'update-extension-extension-0')); + final dialog = find.byKey(const ValueKey('extension-update-dialog')); + final confirm = find.byKey(const ValueKey('confirm-update-extension-button')); + final cancel = find.byKey(const ValueKey('cancel-update-extension-button')); + await tester.tap(entry); + await tester.pumpAndSettle(); + expect(dialog, findsOneWidget); + expect(find.text('Update “Extension 0” to the latest version?'), findsOneWidget); + expect(controller.updateCalls, 0); + await tester.tap(cancel); + await tester.pumpAndSettle(); + expect(dialog, findsNothing); + expect(controller.updateCalls, 0); + + await tester.tap(entry); + await tester.pumpAndSettle(); + controller.failUpdate = true; + await tester.tap(confirm); + await tester.pump(); + expect(controller.updateCalls, 1); + expect(tester.widget(confirm).loading, isTrue); + expect(tester.widget(cancel).onPressed, isNull); + await tester.tap(confirm); + await tester.tapAt(const Offset(5, 5)); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(controller.updateCalls, 1); + expect(dialog, findsOneWidget); + controller.updateGate.complete(); + await tester.pumpAndSettle(); + expect(dialog, findsOneWidget); + expect(find.byKey(const ValueKey('extension-update-error')), findsOneWidget); + expect(tester.widget(confirm).loading, isFalse); + + controller.failUpdate = false; + controller.updateGate = Completer(); + await tester.tap(confirm); + await tester.pump(); + expect(controller.updateCalls, 2); + controller.updateGate.complete(); + await tester.pumpAndSettle(); + expect(dialog, findsNothing); + await tester.pump(const Duration(seconds: 6)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + } + testWidgets('installed extension details use a disabled installed button without a hero status', ( WidgetTester tester, ) async { @@ -709,7 +948,7 @@ void main() { child: shad.ShadcnApp( theme: AppTheme.light(), materialTheme: AppTheme.materialLight(), - home: const ExtensionsPage(), + home: const AppComponentThemes(child: ExtensionsPage()), ), ), ); @@ -801,66 +1040,249 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('extension install actions show progress instead of a disabled icon while busy', ( - WidgetTester tester, - ) async { - await _setTestSize(tester, const Size(1100, 900)); - await tester.pumpWidget( - ProviderScope( - overrides: [extensionsControllerProvider.overrideWith(BusyInstallExtensionsController.new)], - child: shad.ShadcnApp( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - home: const ExtensionsPage(), + testWidgets( + 'URL installation leaves toolbar icons unchanged while store installation shows progress', + (WidgetTester tester) async { + await _setTestSize(tester, const Size(1100, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(BusyInstallExtensionsController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), ), - ), - ); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - - final manualInstall = find.byKey(const ValueKey('install-extension-button')); - final storeInstall = find.byKey(const ValueKey('install-store-extension-extension-1')); - expect(find.descendant(of: manualInstall, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget); - expect(find.descendant(of: storeInstall, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget); - expect(find.descendant(of: storeInstall, matching: find.byIcon(Icons.download)), findsNothing); - expect(tester.takeException(), isNull); - }); - - testWidgets('extension install popover unlocks local loading and installed cards use switches', ( - WidgetTester tester, - ) async { - await _setTestSize(tester, const Size(1100, 900)); - await tester.pumpWidget( - ProviderScope( - overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)], - child: shad.ShadcnApp( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - home: const ExtensionsPage(), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(shad.Switch), findsOneWidget); - expect(find.byKey(const ValueKey('load-local-extension-button')), findsNothing); - final installButton = find.byKey(const ValueKey('install-extension-button')); - expect(find.descendant(of: installButton, matching: find.byType(shad.IconButton)), findsOneWidget); - - for (var index = 0; index < 5; index++) { - await tester.tap(installButton); + ); + await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); - } - expect(find.byKey(const ValueKey('extension-install-popover')), findsOneWidget); - expect(find.byKey(const ValueKey('extension-install-url-input')), findsOneWidget); - final localButton = find.byKey(const ValueKey('load-local-extension-button')); - expect(localButton, findsOneWidget); - expect(find.descendant(of: localButton, matching: find.byType(shad.IconButton)), findsOneWidget); + final manualInstall = find.byKey(const ValueKey('install-extension-button')); + final storeInstall = find.byKey(const ValueKey('install-store-extension-extension-1')); + expect(find.descendant(of: manualInstall, matching: find.byType(shad.CircularProgressIndicator)), findsNothing); + expect(find.descendant(of: manualInstall, matching: find.byIcon(Icons.add_link)), findsOneWidget); + final localInstall = find.byKey(const ValueKey('load-local-extension-button')); + expect(localInstall, findsOneWidget); + expect(find.descendant(of: localInstall, matching: find.byType(shad.CircularProgressIndicator)), findsNothing); + expect(find.descendant(of: localInstall, matching: find.byIcon(Icons.folder_open_outlined)), findsOneWidget); + expect(find.descendant(of: storeInstall, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget); + expect(find.descendant(of: storeInstall, matching: find.byIcon(Icons.download)), findsNothing); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), + ); - await tester.pump(const Duration(seconds: 3)); - expect(tester.takeException(), isNull); - }); + for (final (width, platform) in [ + (320.0, TargetPlatform.android), + (390.0, TargetPlatform.iOS), + (768.0, TargetPlatform.android), + (1100.0, TargetPlatform.macOS), + ]) { + testWidgets( + 'scheme install automatically runs before and after mounting at width $width', + (tester) async { + await _setTestSize(tester, Size(width, 900)); + final controller = FakeExtensionsController()..installGate = Completer(); + final container = ProviderContainer(overrides: [extensionsControllerProvider.overrideWith(() => controller)]); + addTearDown(container.dispose); + const firstUrl = 'https://github.com/author/扩展'; + container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: firstUrl)); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + final input = find.byKey(const ValueKey('extension-install-url-input')); + expect(tester.widget(input).controller!.text, firstUrl); + expect(container.read(pendingExtensionInstallProvider), isNull); + expect(controller.installCalls, 1); + expect(controller.lastInstallUrl, firstUrl); + final popoverRect = tester.getRect(find.byKey(const ValueKey('extension-install-popover'))); + expect(popoverRect.left, greaterThanOrEqualTo(0)); + expect(popoverRect.right, lessThanOrEqualTo(width)); + expect(find.descendant(of: input, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget); + + // Rebuilds and manual submission must not duplicate an active install. + await tester.tap(find.descendant(of: input, matching: find.byType(shad.IconButton))); + await tester.pump(); + expect(controller.installCalls, 1); + controller.installGate!.complete(); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('extension-install-popover')), findsNothing); + + // A warm link automatically installs from an already-open form too. + await tester.tap(find.byKey(const ValueKey('install-extension-button'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + const secondUrl = 'https://github.com/author/second'; + controller.installGate = Completer(); + container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: secondUrl)); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget(input).controller!.text, secondUrl); + expect(controller.installCalls, 2); + expect(controller.lastInstallUrl, secondUrl); + + // A request arriving during installation waits until the active form closes. + container.read(pendingExtensionInstallProvider.notifier).set(InstallExtension(url: firstUrl)); + await tester.pump(); + expect(controller.installCalls, 2); + controller.installGate!.complete(); + controller.installGate = Completer(); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } + expect(controller.installCalls, 3); + expect(controller.lastInstallUrl, firstUrl); + controller.installGate!.complete(); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + }, + variant: TargetPlatformVariant.only(platform), + ); + } + + testWidgets( + 'install URL keeps focus after native menu paste', + (tester) async { + await _setTestSize(tester, const Size(390, 900)); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.hasStrings') return {'value': true}; + if (call.method == 'Clipboard.getData') return {'text': 'https://github.com/author/repo'}; + return null; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + final search = find.byKey(const ValueKey('extension-search-input')); + await tester.tap(search); + await tester.tap(find.byKey(const ValueKey('install-extension-button'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + final input = find.byKey(const ValueKey('extension-install-url-input')); + final editableFinder = find.descendant(of: input, matching: find.byType(EditableText)); + final editable = tester.widget(editableFinder); + expect(editable.focusNode.hasFocus, isTrue); + if (defaultTargetPlatform == TargetPlatform.macOS) { + final gesture = await tester.startGesture( + tester.getCenter(input), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await gesture.up(); + } else { + await tester.longPress(input); + } + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.tap(find.text('Paste')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(input, findsOneWidget); + expect(editable.controller.text, 'https://github.com/author/repo'); + expect(editable.focusNode.hasFocus, isTrue); + expect( + tester + .widget(find.descendant(of: search, matching: find.byType(EditableText))) + .focusNode + .hasFocus, + isFalse, + ); + await tester.tapAt(const Offset(10, 500)); + await tester.pumpAndSettle(); + expect(input, findsNothing); + }, + variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.macOS}), + ); + + testWidgets( + 'mobile does not unlock local extension installation after five taps', + (tester) async { + // A tablet-sized mobile screen must still use mobile platform policy. + await _setTestSize(tester, const Size(1100, 900)); + final controller = FakeExtensionsController(); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(() => controller)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + for (var i = 0; i < 5; i++) { + await tester.tap(find.byKey(const ValueKey('install-extension-button'))); + await tester.pump(const Duration(milliseconds: 100)); + } + expect(controller.devModeEnabled, isFalse); + expect(find.byKey(const ValueKey('load-local-extension-button')), findsNothing); + // Even a pre-existing dev-mode state must not expose the native folder action. + controller.enableDevModeForTest(); + await tester.pump(); + expect(find.byKey(const ValueKey('load-local-extension-button')), findsNothing); + }, + variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}), + ); + + testWidgets( + 'extension install popover unlocks local loading and installed cards use switches', + (WidgetTester tester) async { + await _setTestSize(tester, const Size(1100, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionsControllerProvider.overrideWith(FakeExtensionsController.new)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: const ExtensionsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(shad.Switch), findsOneWidget); + expect(find.byKey(const ValueKey('load-local-extension-button')), findsNothing); + final installButton = find.byKey(const ValueKey('install-extension-button')); + expect(find.descendant(of: installButton, matching: find.byType(shad.IconButton)), findsOneWidget); + + for (var index = 0; index < 5; index++) { + await tester.tap(installButton); + await tester.pump(const Duration(milliseconds: 100)); + } + + expect(find.byKey(const ValueKey('extension-install-popover')), findsOneWidget); + expect(find.byKey(const ValueKey('extension-install-url-input')), findsOneWidget); + final localButton = find.byKey(const ValueKey('load-local-extension-button')); + expect(localButton, findsOneWidget); + expect(find.descendant(of: localButton, matching: find.byType(shad.IconButton)), findsOneWidget); + + await tester.pump(const Duration(seconds: 3)); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant({TargetPlatform.windows, TargetPlatform.macOS, TargetPlatform.linux}), + ); testWidgets('responsive menu uses sidebar on desktop and two-level navigation on mobile', ( WidgetTester tester, @@ -1451,8 +1873,10 @@ void main() { await tester.tap(find.byKey(const ValueKey('close-mcp-agent-setup'))); await tester.pumpAndSettle(); - await tester.ensureVisible(find.byKey(const ValueKey('mcp-endpoint-switch'))); - await tester.tap(find.byKey(const ValueKey('mcp-endpoint-switch'))); + final mcpSwitch = find.byKey(const ValueKey('mcp-endpoint-switch')); + await Scrollable.ensureVisible(tester.element(mcpSwitch), alignment: 0.5); + await tester.pumpAndSettle(); + await tester.tap(mcpSwitch); await tester.pump(); expect(tester.widget(saveButton).onPressed, isNotNull); @@ -1857,6 +2281,40 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('loading button preserves its content dimensions', (WidgetTester tester) async { + var loading = false; + late StateSetter setButtonState; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Center( + child: StatefulBuilder( + builder: (context, update) { + setButtonState = update; + return AppLoadingButton( + key: const ValueKey('dimension-stable-loading-button'), + onPressed: () {}, + loading: loading, + variant: AppLoadingButtonVariant.primary, + child: const Text('Confirm'), + ); + }, + ), + ), + ), + ); + await tester.pump(); + final button = find.byKey(const ValueKey('dimension-stable-loading-button')); + final idleSize = tester.getSize(button); + + setButtonState(() => loading = true); + await tester.pump(); + + expect(tester.getSize(button), idleSize); + expect(find.descendant(of: button, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget); + }); + testWidgets('failed tracker update does not reload or reset the settings page', (WidgetTester tester) async { await _setTestSize(tester, const Size(1024, 900)); final runtimeController = FailingTrackerRuntimeController(); @@ -2180,15 +2638,16 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('settings mobile back returns one level before requiring a second back to exit', ( - WidgetTester tester, - ) async { + testWidgets('settings mobile back returns through home before confirming exit', (WidgetTester tester) async { await _setTestSize(tester, const Size(390, 760)); debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); var systemPopCalls = 0; + bool? frameworkHandlesBack; + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { if (call.method == 'SystemNavigator.pop') systemPopCalls++; + if (call.method == 'SystemNavigator.setFrameworkHandlesBack') frameworkHandlesBack = call.arguments as bool; return null; }); addTearDown(() { @@ -2207,6 +2666,7 @@ void main() { ); await tester.pumpAndSettle(); + expect(frameworkHandlesBack, isTrue); await tester.tap(find.text('Settings')); await tester.pumpAndSettle(); await tester.tap(find.text('Basic')); @@ -2220,6 +2680,13 @@ void main() { expect(find.byKey(const ValueKey('theme-mode-system')), findsNothing); expect(systemPopCalls, 0); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(HomePage), findsOneWidget); + expect(find.text('Press back again to exit'), findsNothing); + expect(systemPopCalls, 0); + expect(frameworkHandlesBack, isTrue); + await tester.binding.handlePopRoute(); await tester.pump(); expect(find.text('Press back again to exit'), findsOneWidget); @@ -2299,10 +2766,12 @@ void main() { await tester.pumpAndSettle(); final createDirectoryInput = find.byKey(const ValueKey('create-task-directory-input')); final createRenameInput = find.byKey(const ValueKey('create-task-rename-input')); + final createConnectionsInput = find.byKey(const ValueKey('create-task-connections-input')); expect(tester.getSize(createDirectoryInput).height, tester.getSize(createRenameInput).height); + expect(tester.getSize(createConnectionsInput), tester.getSize(createRenameInput)); final createDirectoryField = tester.widget(createDirectoryInput); final createRenameField = tester.widget( - find.descendant(of: createRenameInput, matching: find.byType(shad.TextField)), + find.descendant(of: createRenameInput, matching: find.byType(AppTextField)), ); expect(createDirectoryField.filled, createRenameField.filled); expect(createDirectoryField.border, createRenameField.border); @@ -2401,6 +2870,27 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('create task rename stays user-controlled and resets when the URL changes', (WidgetTester tester) async { + await _setTestSize(tester, const Size(700, 500)); + await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness())); + await tester.pump(); + + final urlInput = find.byKey(const ValueKey('create-task-url-input')); + final renameInput = find.byKey(const ValueKey('create-task-rename-input')); + await tester.enterText(urlInput, 'https://example.com/first.zip'); + await tester.enterText(renameInput, 'custom-name.zip'); + expect(find.text('custom-name.zip'), findsOneWidget); + + await tester.enterText(urlInput, 'https://example.com/second.zip'); + await tester.pump(); + + final renameField = tester.widget( + find.descendant(of: renameInput, matching: find.byType(AppTextField)), + ); + expect(renameField.controller!.text, isEmpty); + await tester.pump(const Duration(seconds: 1)); + }); + testWidgets('create task labels direct download without a mode field', (WidgetTester tester) async { await _setTestSize(tester, const Size(700, 500)); await tester.pumpWidget(const ProviderScope(child: _CreateTaskPageHarness())); @@ -2572,9 +3062,9 @@ void main() { padding: const EdgeInsets.all(24), child: ResolveFileTree( files: [ - FileInfo(path: 'videos', name: 'clip.mp4', size: 1024), - FileInfo(path: 'docs', name: 'notes.txt', size: 128), - FileInfo(path: 'docs', name: 'manual.pdf', size: 256), + FileInfo(path: 'videos', name: 'clip.mp4', size: 0), + FileInfo(path: 'docs', name: 'notes.txt', size: 0), + FileInfo(path: 'docs', name: 'manual.pdf', size: 0), ], initialSelection: const [0, 1, 2], onSelectionChanged: (values) => selected = values, @@ -2588,6 +3078,7 @@ void main() { expect(treeFinder, findsOneWidget); final tree = tester.widget>(treeFinder); expect(tree.branchLine, same(shad.BranchLine.path)); + expect(tree.rowHeight, 36); expect( tester.widgetList(find.byType(shad.Checkbox)).every((checkbox) => checkbox.size == null), isTrue, @@ -2610,7 +3101,26 @@ void main() { expect(find.byKey(const ValueKey('resolve-tree-expand-toggle')), findsOneWidget); expect(find.byType(shad.OutlinedContainer), findsOneWidget); expect(find.text('clip.mp4'), findsOneWidget); + expect(find.text('Unknown size'), findsOneWidget); expect(tester.widget(find.text('clip.mp4')).style?.color, AppPalette.light.textPrimary); + final baselines = tester + .widgetList( + find.descendant( + of: find.byKey(const ValueKey('resolve-tree-selection-stats')), + matching: find.byType(Baseline), + ), + ) + .toList(); + expect(baselines, hasLength(2)); + expect(baselines.first.baseline, baselines.last.baseline); + expect(baselines.every((widget) => widget.baselineType == TextBaseline.alphabetic), isTrue); + final filtersRect = tester.getRect(find.byKey(const ValueKey('resolve-tree-filters'))); + final statsRect = tester.getRect(find.byKey(const ValueKey('resolve-tree-selection-stats'))); + final selectedCountRect = tester.getRect(find.byKey(const ValueKey('resolve-tree-selected-count'))); + final selectedSizeRect = tester.getRect(find.byKey(const ValueKey('resolve-tree-selected-size'))); + expect(statsRect.center.dy, closeTo(filtersRect.center.dy, 0.01)); + expect(selectedCountRect.center.dy, closeTo(selectedSizeRect.center.dy, 0.01)); + expect(selectedSizeRect.left, greaterThan(selectedCountRect.right)); await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle'))); await tester.pumpAndSettle(); @@ -2672,8 +3182,16 @@ void main() { expect(find.text('clip.mp4'), findsOneWidget); expect(selected, [0, 1, 2]); - tester.view.physicalSize = const Size(360, 620); + tester.view.physicalSize = const Size(280, 620); await tester.pumpAndSettle(); + expect( + tester.getRect(find.byKey(const ValueKey('resolve-tree-selection-stats'))).top, + greaterThanOrEqualTo(tester.getRect(find.byKey(const ValueKey('resolve-tree-filters'))).bottom), + ); + expect( + tester.getRect(find.byKey(const ValueKey('resolve-tree-selected-size'))).top, + greaterThanOrEqualTo(tester.getRect(find.byKey(const ValueKey('resolve-tree-selected-count'))).bottom), + ); expect(tester.takeException(), isNull); }); @@ -2897,6 +3415,78 @@ void main() { expect(record.isIndeterminate, isTrue); }); + test('known total size preserves zero download speed independently of task status', () { + for (final status in api_task.Status.values) { + for (final total in [0, 1024]) { + for (final speed in [0, 512]) { + final task = _apiTransferTask(id: 'speed', status: status, uploading: false, speed: speed, uploadSpeed: 0); + task.meta.res = Resource(size: total, files: []); + expect(TaskRecord.fromApi(task).speed, total > 0 || speed > 0 ? '$speed B/s' : isNull); + } + } + } + }); + + for (final width in [390.0, 1024.0]) { + testWidgets('known-size task cards retain zero download speed at width $width', (tester) async { + await _setTestSize(tester, Size(width, 220)); + for (final status in api_task.Status.values) { + final task = _apiTransferTask(id: 'zero-speed', status: status, uploading: false, speed: 0, uploadSpeed: 0); + task.meta.res = Resource(size: 1024, files: []); + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: TaskCard( + task: TaskRecord.fromApi(task), + selected: false, + batchMode: false, + selectedInBatch: false, + onPressed: () {}, + onToggleBatch: () {}, + ), + ), + ), + ); + await tester.pump(); + expect(find.text('0 B/s'), findsOneWidget); + expect(find.byIcon(Icons.south), findsOneWidget); + expect(tester.takeException(), isNull); + } + }); + } + + testWidgets('known-size task details retain zero download speed', (tester) async { + await _setTestSize(tester, const Size(700, 900)); + final task = _apiTransferTask( + id: 'zero-details', + status: api_task.Status.running, + uploading: false, + speed: 0, + uploadSpeed: 0, + ); + task.meta.res = Resource(size: 1024, files: []); + await tester.pumpWidget( + ProviderScope( + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: TaskDetailsView( + task: TaskRecord.fromApi(task), + mobile: true, + onOpenStorage: () {}, + onUpdateUrl: (_) async {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('0 B/s'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + test('task records preserve zero upload speed while uploading', () { final record = TaskRecord.fromApi( _apiTransferTask(id: 'seeding', status: api_task.Status.done, uploading: true, speed: 0, uploadSpeed: 0), @@ -3121,7 +3711,7 @@ void main() { expect(find.text('Failed'), findsOneWidget); expect(find.text('Connection refused'), findsNothing); - expect(find.text('10 B/s'), findsNothing); + expect(find.text('10 B/s'), findsOneWidget); expect(tester.getRect(find.text('50 B / 100 B')).left, lessThan(tester.getRect(find.text('Failed')).left)); }); @@ -3396,6 +3986,14 @@ void main() { expect(urlValue.data, contains('\u200B')); expect(urlValue.data!.replaceAll('\u200B', ''), task.url); expect(urlValue.semanticsLabel, task.url); + final selectionArea = find.byKey(const ValueKey('task-details-selection-area')); + expect(selectionArea, findsOneWidget); + expect(tester.widget(selectionArea), isA()); + expect(find.ancestor(of: find.text('details.zip'), matching: selectionArea), findsOneWidget); + expect( + find.ancestor(of: find.byKey(const ValueKey('task-details-url-value')), matching: selectionArea), + findsOneWidget, + ); expect(find.text('Task Details'), findsOneWidget); expect(find.text('Task Name'), findsNothing); final detailHeader = tester.widget(find.byKey(const ValueKey('app-detail-drawer-header'))); @@ -4339,8 +4937,8 @@ void main() { expect(find.byType(AppHttpHeadersEditor), findsOneWidget); final firstName = find.byKey(const ValueKey('update-task-http-header-name-0')); final firstValue = find.byKey(const ValueKey('update-task-http-header-value-0')); - final firstNameField = find.descendant(of: firstName, matching: find.byType(shad.TextField)); - final firstValueField = find.descendant(of: firstValue, matching: find.byType(shad.TextField)); + final firstNameField = find.descendant(of: firstName, matching: find.byType(AppTextField)); + final firstValueField = find.descendant(of: firstValue, matching: find.byType(AppTextField)); expect(tester.widget(firstNameField).controller!.text, 'Authorization'); expect(tester.widget(firstValueField).controller!.text, 'Bearer old'); expect(tester.getSize(firstValue).width / tester.getSize(firstName).width, closeTo(1.618, 0.01)); @@ -4663,45 +5261,100 @@ void main() { expect(openedCount, 0); }); - testWidgets('completed task double click opens while folder action reveals', (WidgetTester tester) async { - await _setTestSize(tester, const Size(1024, 220)); - var openedCount = 0; - var revealedCount = 0; + for (final width in [390.0, 1024.0]) { + testWidgets( + 'completed card actions respect platform at width $width', + (tester) async { + await _setTestSize(tester, Size(width, 220)); + var deleted = 0; + var revealed = 0; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: TaskCard( + task: _taskRecord(id: 'platform-actions', name: 'done.zip', status: TaskStatus.completed), + selected: false, + batchMode: false, + selectedInBatch: false, + onPressed: () {}, + onToggleBatch: () {}, + onDelete: () => deleted++, + onReveal: () => revealed++, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final desktop = + !kIsWeb && + {TargetPlatform.windows, TargetPlatform.macOS, TargetPlatform.linux}.contains(defaultTargetPlatform); + expect(find.byIcon(Icons.folder_open_outlined), desktop ? findsOneWidget : findsNothing); + expect(find.byIcon(Icons.delete_outline), findsOneWidget); + if (desktop) { + await tester.tap(find.byIcon(Icons.folder_open_outlined)); + expect(revealed, 1); + } + await tester.tap(find.byIcon(Icons.delete_outline)); + expect(deleted, 1); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant({ + TargetPlatform.android, + TargetPlatform.iOS, + TargetPlatform.windows, + TargetPlatform.macOS, + TargetPlatform.linux, + }), + ); + } - await tester.pumpWidget( - shad.ShadcnApp( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - home: Padding( - padding: const EdgeInsets.all(24), - child: TaskCard( - task: _taskRecord(id: 'completed-actions', name: 'completed.zip', status: TaskStatus.completed), - selected: false, - batchMode: false, - selectedInBatch: false, - onPressed: () {}, - onToggleBatch: () {}, - onOpen: () => openedCount++, - onReveal: () => revealedCount++, + testWidgets( + 'completed task double click opens while folder action reveals', + (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 220)); + var openedCount = 0; + var revealedCount = 0; + + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Padding( + padding: const EdgeInsets.all(24), + child: TaskCard( + task: _taskRecord(id: 'completed-actions', name: 'completed.zip', status: TaskStatus.completed), + selected: false, + batchMode: false, + selectedInBatch: false, + onPressed: () {}, + onToggleBatch: () {}, + onOpen: () => openedCount++, + onReveal: () => revealedCount++, + ), ), ), - ), - ); + ); - await tester.tap(find.text('completed.zip')); - await tester.pump(kDoubleTapMinTime); - await tester.tap(find.text('completed.zip')); - await tester.pumpAndSettle(); + await tester.tap(find.text('completed.zip')); + await tester.pump(kDoubleTapMinTime); + await tester.tap(find.text('completed.zip')); + await tester.pumpAndSettle(); - expect(openedCount, 1); - expect(revealedCount, 0); + expect(openedCount, 1); + expect(revealedCount, 0); - await tester.tap(find.byIcon(Icons.folder_open_outlined)); - await tester.pump(); + await tester.tap(find.byIcon(Icons.folder_open_outlined)); + await tester.pump(); - expect(openedCount, 1); - expect(revealedCount, 1); - }); + expect(openedCount, 1); + expect(revealedCount, 1); + }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), + skip: kIsWeb, + ); } Future _setTestSize(WidgetTester tester, Size size) async { @@ -4816,7 +5469,7 @@ class _CreateTaskPageHarness extends StatelessWidget { return shad.ShadcnApp( theme: AppTheme.light(), materialTheme: AppTheme.materialLight(), - home: const CreateTaskWindowPage(), + home: const AppComponentThemes(child: CreateTaskWindowPage()), ); } } @@ -4832,7 +5485,23 @@ class FailingTasksController extends TasksController { } class FakeExtensionsController extends ExtensionsController { + bool get devModeEnabled => state.requireValue.devMode; + + void enableDevModeForTest() { + state = AsyncValue.data(state.requireValue.copyWith(devMode: true)); + } + int installCalls = 0; + String? lastInstallUrl; + Completer? installGate; + + @override + Future installFromUrl(String url, {bool devInstall = false}) async { + installCalls++; + lastInstallUrl = url; + await installGate?.future; + } + int removeCalls = 0; @override @@ -4893,6 +5562,52 @@ class FakeExtensionsController extends ExtensionsController { } } +class CrowdedExtensionCardController extends ExtensionsController { + @override + Future build() async { + final installed = + api_extension.Extension( + identity: 'crowded-extension', + name: 'crowded-extension', + author: 'Gopeed', + title: 'Crowded extension', + description: 'An extension with every available card action.', + icon: '', + version: '1.0.0', + homepage: 'https://gopeed.com', + repository: api_extension.Repository(url: 'https://github.com/GopeedLab/gopeed', directory: ''), + disabled: false, + devMode: false, + devPath: '', + ) + ..settings = [ + api_extension.Setting( + name: 'enabled', + title: 'Enabled', + description: 'Enable the extension.', + required: false, + type: api_extension.SettingType.boolean, + ), + ]; + final store = StoreExtension( + id: 'crowded-extension', + repoFullName: 'GopeedLab/gopeed', + repoUrl: 'https://github.com/GopeedLab/gopeed', + name: 'crowded-extension', + author: 'Gopeed', + title: 'Crowded extension', + description: 'An extension with every available card action.', + readme: '# Crowded extension', + homepage: 'https://gopeed.com', + version: '1.1.0', + installCount: 1234567890, + stars: 12345, + topics: ['download'], + ); + return ExtensionsState(installedExtensions: [installed], storeExtensions: [store]); + } +} + class DevReadmeExtensionsController extends FakeExtensionsController { DevReadmeExtensionsController(this.extensionDirectory); @@ -4953,11 +5668,31 @@ class PaginatedExtensionsController extends FakeExtensionsController { } } +class UpdateableExtensionsController extends FakeExtensionsController { + int updateCalls = 0; + bool failUpdate = false; + Completer updateGate = Completer(); + + @override + Future build() async => + (await super.build()).copyWith(listFilter: ExtensionListFilter.installed, updateFlags: {'extension-0': '2.0.0'}); + + @override + Future upgradeExtension(api_extension.Extension extension) async { + updateCalls++; + await updateGate.future; + if (failUpdate) throw StateError('update failed'); + } +} + class BusyInstallExtensionsController extends FakeExtensionsController { @override Future build() async { final initial = await super.build(); - return initial.copyWith(busyExtensionIds: {ExtensionsController.manualInstallBusyKey, 'extension-1'}); + return initial.copyWith( + devMode: true, + busyExtensionIds: {ExtensionsController.manualInstallBusyKey, 'extension-1'}, + ); } } diff --git a/ui/flutter/web/manifest.json b/ui/flutter/web/manifest.json index 3e285875..d75201fc 100644 --- a/ui/flutter/web/manifest.json +++ b/ui/flutter/web/manifest.json @@ -1,6 +1,6 @@ { - "name": "gopeed", - "short_name": "gopeed", + "name": "Gopeed", + "short_name": "Gopeed", "start_url": ".", "display": "standalone", "background_color": "#0175C2", diff --git a/ui/flutter/windows/runner/main.cpp b/ui/flutter/windows/runner/main.cpp index c8c65677..74a39e96 100644 --- a/ui/flutter/windows/runner/main.cpp +++ b/ui/flutter/windows/runner/main.cpp @@ -56,7 +56,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, flutter::DartProject project(L"data"); std::vector command_line_arguments = GetCommandLineArguments(); if (!IsMultiWindowLaunch(command_line_arguments) && - SendAppLinkToInstance(L"gopeed")) { + SendAppLinkToInstance(L"Gopeed")) { return EXIT_SUCCESS; } @@ -71,7 +71,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.Create(L"gopeed", origin, size)) { + if (!window.Create(L"Gopeed", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true);