diff --git a/README.md b/README.md index a5f84853..a8a5ce17 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 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) +- [Download Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3) 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 8e0034f2..fabaabb7 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 2 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) +- [Gopeed 2.0.0 Beta 3 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3) 機能と安定性が正式リリースの基準に達した時点で、Gopeed 2.0.0 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。 diff --git a/README_vi-VN.md b/README_vi-VN.md index 97af5bcf..b519dbfa 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 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) +- [Tải Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3) 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 46446941..bd05d0aa 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 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) +- [下载 Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3) 当功能完整度和稳定性达到正式发布标准后,我们会发布 Gopeed 2.0.0 正式版。已安装 Beta 版本的用户可以直接升级到最终正式版,现有稳定版用户则不会被自动切换到 Beta 渠道。 diff --git a/README_zh-TW.md b/README_zh-TW.md index 0a05b7a9..40015dd4 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 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) +- [下載 Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3) 當功能完整度與穩定性達到正式發布標準後,我們會發布 Gopeed 2.0.0 正式版。已安裝 Beta 版本的使用者可以直接升級至最終正式版,現有穩定版使用者則不會被自動切換至 Beta 頻道。 diff --git a/_docs/img/ui-concept-en.png b/_docs/img/ui-concept-en.png index a668d7ca..5d267e9b 100644 Binary files a/_docs/img/ui-concept-en.png and b/_docs/img/ui-concept-en.png differ diff --git a/_docs/img/ui-concept-zh-CN.png b/_docs/img/ui-concept-zh-CN.png index 78c2377d..a7d721bc 100644 Binary files a/_docs/img/ui-concept-zh-CN.png and b/_docs/img/ui-concept-zh-CN.png differ diff --git a/internal/protocol/bt/selection_test.go b/internal/protocol/bt/selection_test.go index 8c2f9773..b5197f63 100644 --- a/internal/protocol/bt/selection_test.go +++ b/internal/protocol/bt/selection_test.go @@ -83,10 +83,15 @@ func TestWaitCleansUnselectedFilesFromSharedPiece(t *testing.T) { {Length: 4, Path: []string{"keep.txt"}}, {Length: 4, Path: []string{"nested", "skip.txt"}}, }} - store := storage.NewFileOpts(storage.NewFileClientOpts{ + storageOpts := storage.NewFileClientOpts{ ClientBaseDir: dir, TorrentDirMaker: func(baseDir string, _ *metainfo.Info, _ metainfo.Hash) string { return baseDir }, - }) + } + // VerifyDataContext can return before storage finishes marking the piece + // complete. Disable automatic part-file promotion so it cannot rename + // the .part fixtures that this test creates to exercise cleanup. + storageOpts.UsePartFiles.Set(false) + store := storage.NewFileOpts(storageOpts) defer store.Close() config := torrent.TestingConfig(t) config.DisableTCP = true diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index 7922c003..9eca0089 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -150,7 +150,7 @@ type slowStartController struct { totalLaunched int batchPending int // Connections in current batch waiting for HTTP response batchReady int // Connections in current batch that succeeded - nextBatchSize int // Next batch size: 1, 2, 4, 8... + nextBatchSize int // Next batch size: 1, 2, 4, 16, 256... (capped) expansionCh chan struct{} // Signal to trigger next expansion paused bool // Pause expansion (e.g., on 429) } @@ -229,7 +229,15 @@ func (s *slowStartController) commitBatch(count int) { defer s.mu.Unlock() s.totalLaunched += count - s.nextBatchSize = s.nextBatchSize * 2 // Exponential growth: 1, 2, 4, 8... + if s.nextBatchSize == 1 { + // Bootstrap growth: squaring one would never increase the batch size. + s.nextBatchSize = min(2, s.maxConnections) + } else if s.nextBatchSize > s.maxConnections/s.nextBatchSize { + // Cap before multiplying to avoid integer overflow. + s.nextBatchSize = s.maxConnections + } else { + s.nextBatchSize *= s.nextBatchSize + } s.batchPending = count s.batchReady = 0 } @@ -409,12 +417,26 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error { res.Range = true } - // Get content length from Content-Length header - contentLength := resp.Header.Get(base.HttpHeaderContentLength) - if contentLength != "" { - parse, err := strconv.ParseInt(contentLength, 10, 64) - if err == nil { - res.Size = parse + // A partial response describes one segment, not the whole resource. + reuseResolveBody := true + if resp.StatusCode == base.HttpCodePartialContent { + start, _, total, err := parseRangeResponse(resp) + if err != nil { + resp.Body.Close() + f.setState(stateError) + return err + } + res.Size = total + res.Range = true + // Prefetch is a contiguous prefix copied at offset zero. A segment + // starting elsewhere must be fetched again through the Range path. + reuseResolveBody = start == 0 + } else { + contentLength := resp.Header.Get(base.HttpHeaderContentLength) + if contentLength != "" { + if size, err := strconv.ParseInt(contentLength, 10, 64); err == nil { + res.Size = size + } } } @@ -459,7 +481,12 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error { // IMPORTANT: Keep the response body open for downloading in Start phase // This is crucial for one-time URLs that can only be accessed once f.resolveRespLock.Lock() - f.resolveResp = resp + if reuseResolveBody { + f.resolveResp = resp + } else { + resp.Body.Close() + f.prefetchDone.Store(true) + } f.resolveRespLock.Unlock() f.setState(stateResolved) @@ -471,7 +498,7 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error { // Start async prefetch in background (only for range-supported resources) // For non-range resources, the response will be used directly in Start - if res.Range { + if res.Range && reuseResolveBody { f.prefetchStopCh = make(chan struct{}) f.resolveFallback.Store(true) go f.asyncPrefetch() @@ -1536,27 +1563,41 @@ func (f *Fetcher) completeFromResolveFallback(conn *connection) bool { return true } -func validateRangeResponse(resp *http.Response, requestedStart int64, requestedEnd *int64, totalSize int64) (int64, int64, error) { +// parseRangeResponse validates a single byte range with a known complete length. +// Unknown totals are rejected: a segment length cannot stand in for resource size. +func parseRangeResponse(resp *http.Response) (int64, int64, int64, error) { contentRange := resp.Header.Get(base.HttpHeaderContentRange) fields := strings.Fields(contentRange) if len(fields) != 2 || !strings.EqualFold(fields[0], base.HttpHeaderBytes) { - return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange) + return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange) } rangeAndTotal := strings.Split(fields[1], "/") if len(rangeAndTotal) != 2 { - return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange) + return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange) } bounds := strings.Split(rangeAndTotal[0], "-") if len(bounds) != 2 { - return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange) + return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange) } start, startErr := strconv.ParseInt(bounds[0], 10, 64) end, endErr := strconv.ParseInt(bounds[1], 10, 64) total, totalErr := strconv.ParseInt(rangeAndTotal[1], 10, 64) if startErr != nil || endErr != nil || totalErr != nil || start < 0 || end < start || total <= end { - return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange) + return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange) + } + expectedLength := end - start + 1 + if resp.ContentLength >= 0 && resp.ContentLength != expectedLength { + return 0, 0, 0, fmt.Errorf("%w: Content-Length %d does not match range length %d", errInvalidRangeResponse, resp.ContentLength, expectedLength) + } + return start, end, total, nil +} + +func validateRangeResponse(resp *http.Response, requestedStart int64, requestedEnd *int64, totalSize int64) (int64, int64, error) { + start, end, total, err := parseRangeResponse(resp) + if err != nil { + return 0, 0, err } if start != requestedStart || (requestedEnd != nil && end != *requestedEnd) || (requestedEnd == nil && end != total-1) { return 0, 0, fmt.Errorf("%w: Content-Range %d-%d does not match requested range", errInvalidRangeResponse, start, end) @@ -1565,11 +1606,7 @@ func validateRangeResponse(resp *http.Response, requestedStart int64, requestedE return 0, 0, fmt.Errorf("%w: Content-Range total %d does not match resource size %d", errInvalidRangeResponse, total, totalSize) } - expectedLength := end - start + 1 - if resp.ContentLength >= 0 && resp.ContentLength != expectedLength { - return 0, 0, fmt.Errorf("%w: Content-Length %d does not match range length %d", errInvalidRangeResponse, resp.ContentLength, expectedLength) - } - return expectedLength, total, nil + return end - start + 1, total, nil } func (f *Fetcher) resolveOpenEndedRange(conn *connection, totalSize int64) error { diff --git a/internal/protocol/http/resolve_range_test.go b/internal/protocol/http/resolve_range_test.go new file mode 100644 index 00000000..d7e0b335 --- /dev/null +++ b/internal/protocol/http/resolve_range_test.go @@ -0,0 +1,119 @@ +package http + +import ( + "bytes" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/GopeedLab/gopeed/pkg/base" + fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http" +) + +func TestResolveResponseSize(t *testing.T) { + for _, tc := range []struct { + name string + status int + length, contentRange string + size int64 + invalid bool + }{ + {"full", 200, "801571015", "", 801571015, false}, + {"partial_prefix", 206, "472321339", "bytes 0-472321338/801571015", 801571015, false}, + {"partial_nonzero", 206, "100000", "bytes 100000-199999/801571015", 801571015, false}, + {"second_report", 206, "335134409", "bytes 0-335134408/552140234", 552140234, false}, + {"length_mismatch", 206, "10", "bytes 0-19/100", 0, true}, + {"unknown_total", 206, "472321339", "bytes 0-472321338/*", 0, true}, + {"missing_range", 206, "10", "", 0, true}, + {"malformed_range", 206, "10", "bytes invalid", 0, true}, + {"invalid_bounds", 206, "10", "bytes 20-10/100", 0, true}, + {"invalid_total", 206, "10", "bytes 0-9/9", 0, true}, + {"overflow", 206, "10", "bytes 0-9/9223372036854775808", 0, true}, + {"chunked_partial", 206, "", "bytes 0-9/100", 100, false}, + {"unknown_full", 200, "", "", 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tc.length != "" { + w.Header().Set("Content-Length", tc.length) + } + if tc.contentRange != "" { + w.Header().Set("Content-Range", tc.contentRange) + } + w.WriteHeader(tc.status) + w.(http.Flusher).Flush() + })) + defer server.Close() + f := buildFetcher() + defer f.Close() + err := f.Resolve(&base.Request{URL: server.URL + "/file"}, &base.Options{Path: t.TempDir()}) + if tc.invalid { + if !errors.Is(err, errInvalidRangeResponse) { + t.Fatalf("Resolve error = %v, want invalid range", err) + } + if f.meta.Res != nil || f.resolveResp != nil || f.getState() != stateError { + t.Fatal("invalid response was retained or published") + } + return + } + if err != nil { + t.Fatal(err) + } + if f.meta.Res.Size != tc.size || f.meta.Res.Files[0].Size != tc.size { + t.Fatalf("resource/file size = %d/%d, want %d", f.meta.Res.Size, f.meta.Res.Files[0].Size, tc.size) + } + if tc.status == 206 && !f.meta.Res.Range { + t.Fatal("partial response must enable Range downloads") + } + }) + } +} + +func TestDownloadPartialResolve(t *testing.T) { + payload := make([]byte, 256*1024) + for i := range payload { + payload[i] = byte(i*31 + i/251) + } + for _, start := range []int{0, 100000} { + for _, connections := range []int{1, 4} { + t.Run(fmt.Sprintf("start_%d_connections_%d", start, connections), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + begin, end := start, start+9999 + if h := r.Header.Get("Range"); h != "" { + if _, err := fmt.Sscanf(h, "bytes=%d-%d", &begin, &end); err != nil { + t.Errorf("invalid request range %q: %v", h, err) + w.WriteHeader(400) + return + } + } + w.Header().Set("Content-Length", fmt.Sprint(end-begin+1)) + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", begin, end, len(payload))) + w.WriteHeader(206) + w.Write(payload[begin : end+1]) + })) + defer server.Close() + f := buildFetcher() + defer f.Close() + if err := f.Resolve(&base.Request{URL: server.URL + "/file"}, &base.Options{Path: t.TempDir(), Name: "file", Extra: &fhttp.OptsExtra{Connections: connections}}); err != nil { + t.Fatal(err) + } + if err := f.Start(); err != nil { + t.Fatal(err) + } + if err := f.Wait(); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(f.meta.SingleFilepath()) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("download content mismatch: got %d bytes, want %d", len(got), len(payload)) + } + }) + } + } +} diff --git a/internal/webview/README.md b/internal/webview/README.md index 8025132f..e660fcea 100644 --- a/internal/webview/README.md +++ b/internal/webview/README.md @@ -61,10 +61,13 @@ Go proxy unit tests separately cover successful TLS forwarding and authenticatio 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. -## Extension events and host version +## Extension events and host environment -`gopeed.info.hostVersion` is the host's build version (`dev` for development +`gopeed.host.env.version` is the host's build version (`dev` for development builds). `gopeed.info.version` continues to identify the extension's own version. +`gopeed.host.env.os` and `gopeed.host.env.arch` match the REST info endpoint's `os` and +`arch`: the host's Go `runtime.GOOS` and `runtime.GOARCH` values (for example, +`windows` / `amd64` or `darwin` / `arm64`). See [extension-api.d.ts](extension-api.d.ts) for the event payloads. ```js diff --git a/internal/webview/extension-api.d.ts b/internal/webview/extension-api.d.ts index fa5528dd..f55582ca 100644 --- a/internal/webview/extension-api.d.ts +++ b/internal/webview/extension-api.d.ts @@ -5,7 +5,19 @@ export interface ExtensionInfo { author: string; title: string; version: string; - hostVersion: string; +} + +/** Available as gopeed.host. */ +export interface Host { + env: HostEnv; +} + +export interface HostEnv { + /** Host build version; "dev" for development builds. */ + version: string; + /** Host OS and architecture, matching the REST info endpoint (Go GOOS/GOARCH). */ + os: string; + arch: string; } /** Functions are serialized; they cannot capture extension-side variables. */ diff --git a/pkg/base/model.go b/pkg/base/model.go index 7b49b297..e60bfffe 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -25,24 +25,6 @@ type Request struct { SkipVerifyCert bool `json:"skipVerifyCert"` } -// SetLabels replaces all labels on the request. -func (r *Request) SetLabels(labels map[string]string) { - r.Labels = labels -} - -// PutLabel sets a label on the request. -func (r *Request) PutLabel(key, value string) { - if r.Labels == nil { - r.Labels = make(map[string]string) - } - r.Labels[key] = value -} - -// DelLabel deletes a label from the request. -func (r *Request) DelLabel(key string) { - delete(r.Labels, key) -} - func (r *Request) Validate() error { if r.URL == "" { return fmt.Errorf("invalid request url") diff --git a/pkg/download/downloader_test.go b/pkg/download/downloader_test.go index 98ada366..545effb3 100644 --- a/pkg/download/downloader_test.go +++ b/pkg/download/downloader_test.go @@ -45,6 +45,32 @@ type generationTestManager struct { func TestExtensionTaskMethodsMatchEventCapabilities(t *testing.T) { taskType := reflect.TypeOf((*Task)(nil)) + for _, typ := range []reflect.Type{taskType, reflect.TypeOf((*ExtensionTask)(nil)), reflect.TypeOf((*OnErrorExtensionTask)(nil))} { + for _, name := range []string{"SetUrl", "SetExtra", "PatchExtra", "PatchHeaders", "SetMethod", "SetBody", "SetHeaders", "PutHeader", "DelHeader", "SetTrackers"} { + if _, ok := typ.MethodByName(name); ok { + t.Fatalf("%v unexpectedly exposes %s", typ, name) + } + } + } + requestType := reflect.TypeOf((*ExtensionTaskRequest)(nil)) + for _, name := range []string{"SetUrl", "SetLabels", "PutLabel", "DelLabel", "SetMethod", "SetBody", "SetHeaders", "PutHeader", "DelHeader", "SetTrackers"} { + if _, ok := requestType.MethodByName(name); !ok { + t.Fatalf("request wrapper does not expose %s", name) + } + } + for _, typ := range []reflect.Type{reflect.TypeOf(ExtensionTask{}), reflect.TypeOf(OnErrorExtensionTask{})} { + if _, ok := typ.FieldByName("Req"); ok { + t.Fatalf("%v exposes the removed task.req entry", typ) + } + field, ok := typ.FieldByName("Meta") + if !ok || field.Type != reflect.TypeOf((*ExtensionTaskMeta)(nil)) { + t.Fatalf("%v has no metadata wrapper", typ) + } + } + field, ok := reflect.TypeOf(ExtensionTaskMeta{}).FieldByName("Req") + if !ok || field.Type != requestType { + t.Fatal("meta.req has no request wrapper") + } if _, ok := taskType.MethodByName("Continue"); ok { t.Fatal("regular task unexpectedly exposes Continue") } @@ -53,17 +79,11 @@ func TestExtensionTaskMethodsMatchEventCapabilities(t *testing.T) { } extensionTaskType := reflect.TypeOf((*ExtensionTask)(nil)) - if _, ok := extensionTaskType.MethodByName("SetUrl"); !ok { - t.Fatal("extension task does not expose SetUrl") - } if _, ok := extensionTaskType.MethodByName("Continue"); ok { t.Fatal("non-error extension task unexpectedly exposes Continue") } errorTaskType := reflect.TypeOf((*OnErrorExtensionTask)(nil)) - if _, ok := errorTaskType.MethodByName("SetUrl"); !ok { - t.Fatal("onError task does not expose SetUrl") - } if _, ok := errorTaskType.MethodByName("Continue"); !ok { t.Fatal("onError task does not expose Continue") } diff --git a/pkg/download/extension.go b/pkg/download/extension.go index 74651c4b..34128e52 100644 --- a/pkg/download/extension.go +++ b/pkg/download/extension.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/internal/logger" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/download/engine" @@ -735,6 +736,14 @@ type OnDoneContext struct { // only some fields can be modified, such as request info. type ExtensionTask struct { *Task + Meta *ExtensionTaskMeta `json:"meta"` +} + +// ExtensionTaskMeta preserves metadata fields while exposing request operations +// at the existing meta.req path. +type ExtensionTaskMeta struct { + *fetcher.FetcherMeta + Req *ExtensionTaskRequest `json:"req"` } // OnErrorExtensionTask adds error-recovery controls to ExtensionTask. @@ -752,7 +761,14 @@ func cloneExtensionTask(task *Task) *Task { } func newExtensionTask(task *Task) *ExtensionTask { - return &ExtensionTask{Task: cloneExtensionTask(task)} + view := cloneExtensionTask(task) + return &ExtensionTask{ + Task: view, + Meta: &ExtensionTaskMeta{ + FetcherMeta: view.Meta, + Req: &ExtensionTaskRequest{Request: view.Meta.Req, task: view}, + }, + } } func newOnStartExtensionTask(task *Task) *ExtensionTask { @@ -770,11 +786,6 @@ func newOnDoneExtensionTask(task *Task) *Task { return cloneExtensionTask(task) } -// SetUrl replaces the task request URL. -func (t *ExtensionTask) SetUrl(url string) { - t.Meta.Req.URL = url -} - func (t *OnErrorExtensionTask) Continue() error { return t.download.Continue(&TaskFilter{ IDs: []string{t.ID}, diff --git a/pkg/download/extension_extra_mutation_test.go b/pkg/download/extension_extra_mutation_test.go new file mode 100644 index 00000000..d95ec369 --- /dev/null +++ b/pkg/download/extension_extra_mutation_test.go @@ -0,0 +1,188 @@ +package download + +import ( + "fmt" + "reflect" + "testing" + + "github.com/GopeedLab/gopeed/internal/fetcher" + "github.com/GopeedLab/gopeed/pkg/base" + btProtocol "github.com/GopeedLab/gopeed/pkg/protocol/bt" + httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http" + "github.com/dop251/goja" +) + +func TestExtensionProtocolMethods(t *testing.T) { + for _, extra := range []any{nil, map[string]any{"header": map[string]any{"Keep": "yes"}}, &httpProtocol.ReqExtra{Header: map[string]string{"Keep": "yes"}}} { + vm := goja.New() + vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) + req := &base.Request{Extra: extra} + task := &Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}} + if err := vm.Set("task", newOnStartExtensionTask(task)); err != nil { + t.Fatal(err) + } + _, err := vm.RunString(`task.meta.req.setMethod('POST'); task.meta.req.setBody('payload'); task.meta.req.putHeader('Authorization','Bearer token'); task.meta.req.putHeader('Remove','remove'); task.meta.req.delHeader('REMOVE');`) + if err != nil { + t.Fatal(err) + } + if err := base.ParseReqExtra[httpProtocol.ReqExtra](req); err != nil { + t.Fatal(err) + } + got := req.Extra.(*httpProtocol.ReqExtra) + if got.Method != "POST" || got.Body != "payload" || got.Header["Authorization"] != "Bearer token" { + t.Fatalf("mutation not applied: %#v", got) + } + if _, ok := got.Header["Remove"]; ok { + t.Fatal("header deletion failed") + } + if extra != nil && got.Header["Keep"] != "yes" { + t.Fatal("existing header lost") + } + } + vm := goja.New() + vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) + req := &base.Request{Extra: &btProtocol.ReqExtra{Trackers: []string{"old"}}} + task := &Task{Protocol: "bt", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}} + if err := vm.Set("task", newOnStartExtensionTask(task)); err != nil { + t.Fatal(err) + } + if _, err := vm.RunString(`task.meta.req.setTrackers(['new','another']);`); err != nil { + t.Fatal(err) + } + got := req.Extra.(*btProtocol.ReqExtra).Trackers + if len(got) != 2 || got[0] != "new" || got[1] != "another" { + t.Fatalf("trackers = %#v", got) + } +} + +func TestExtensionProtocolMethodsRejectInvalidMutation(t *testing.T) { + vm := goja.New() + vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) + original := &httpProtocol.ReqExtra{Method: "POST", Header: map[string]string{"authorization": "old", "Keep": "yes"}} + req := &base.Request{Extra: original} + task := newOnStartExtensionTask(&Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}) + if err := vm.Set("task", task); err != nil { + t.Fatal(err) + } + for _, script := range []string{`task.meta.req.setHeaders({Keep:null})`, `task.meta.req.setHeaders({Keep:123})`, `task.meta.req.setHeaders({a:'1',A:'2'})`} { + if _, err := vm.RunString(script); err == nil { + t.Fatalf("accepted invalid mutation: %s", script) + } + if req.Extra != original || original.Header["Keep"] != "yes" { + t.Fatal("invalid operation changed original extra") + } + } + if _, err := vm.RunString(`task.meta.req.putHeader('Authorization','new');`); err != nil { + t.Fatal(err) + } + got := req.Extra.(*httpProtocol.ReqExtra) + if len(got.Header) != 2 || got.Header["Authorization"] != "new" || original.Header["authorization"] != "old" { + t.Fatalf("header update = %#v", got.Header) + } + if _, err := vm.RunString(`task.meta.req.setHeaders({Only:'one'});`); err != nil { + t.Fatal(err) + } + got = req.Extra.(*httpProtocol.ReqExtra) + if got.Method != "POST" || len(got.Header) != 1 { + t.Fatalf("setHeaders = %#v", got) + } + req.Extra = map[string]any{"header": 123} + if _, err := vm.RunString(`task.meta.req.setBody('new')`); err == nil { + t.Fatal("invalid protocol extra accepted") + } + if req.Extra.(map[string]any)["header"] != 123 { + t.Fatal("conversion failure mutated extra") + } +} + +func TestExtensionProtocolMethodsOtherProtocolsAreNoOp(t *testing.T) { + for _, protocol := range []string{"http", "bt", "hls"} { + for _, onError := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/onError=%v", protocol, onError), func(t *testing.T) { + vm := goja.New() + vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) + // An invalid extra proves that unrelated methods skip conversion entirely. + original := map[string]any{"unchanged": true, "header": 123} + req := &base.Request{Extra: original} + task := &Task{Protocol: protocol, Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}} + var wrapper any = newOnStartExtensionTask(task) + if onError { + wrapper = newOnErrorExtensionTask(nil, task) + } + if err := vm.Set("task", wrapper); err != nil { + t.Fatal(err) + } + if protocol != "http" { + if _, err := vm.RunString(`task.meta.req.setMethod('POST');task.meta.req.setBody('body');task.meta.req.setHeaders({a:null});task.meta.req.putHeader('A','value');task.meta.req.delHeader('A');`); err != nil { + t.Fatal(err) + } + } + if protocol != "bt" { + if _, err := vm.RunString(`task.meta.req.setTrackers(['udp://example.com']);`); err != nil { + t.Fatal(err) + } + } + if !reflect.DeepEqual(req.Extra, original) { + t.Fatalf("extra changed: %#v", req.Extra) + } + original["same-object"] = true + if req.Extra.(map[string]any)["same-object"] != true { + t.Fatal("no-op replaced extra") + } + }) + } + } +} + +func TestExtensionOnErrorRequestWritesToOriginalTask(t *testing.T) { + vm := goja.New() + vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) + req := &base.Request{URL: "https://example.com", Extra: &httpProtocol.ReqExtra{Header: map[string]string{"Keep": "yes"}}} + task := &Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}} + if err := vm.Set("task", newOnErrorExtensionTask(nil, task)); err != nil { + t.Fatal(err) + } + if _, err := vm.RunString(` + if (typeof task.continue !== 'function') throw new Error('missing continue'); + if (typeof task.putHeader !== 'undefined') throw new Error('task still exposes protocol methods'); + if (typeof task.req !== 'undefined') throw new Error('legacy task.req'); + const request = task.meta.req; + if (request.url !== 'https://example.com' || request.extra.header.Keep !== 'yes') throw new Error('request data missing'); + if (!task.meta.opts) throw new Error('metadata fields missing'); + request.setHeaders({Keep:'new'}); + request.putHeader('Authorization','Bearer new'); + request.setMethod('POST'); + if (typeof task.setUrl !== 'undefined' || typeof task.extra !== 'undefined') throw new Error('legacy task API'); + request.setUrl('https://example.com/new'); + request.setLabels({keep:'yes',remove:'yes'}); + request.putLabel('new','value'); + request.delLabel('remove'); + if (request.url !== 'https://example.com/new' || request.labels.new !== 'value' || request.extra.method !== 'POST' || request.extra.header.Authorization !== 'Bearer new') throw new Error('request data did not refresh'); + `); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(req.Labels, map[string]string{"keep": "yes", "new": "value"}) { + t.Fatalf("labels = %#v", req.Labels) + } + got := req.Extra.(*httpProtocol.ReqExtra) + if got.Method != "POST" || got.Header["Keep"] != "new" || got.Header["Authorization"] != "Bearer new" || req.URL != "https://example.com/new" { + t.Fatalf("original request not updated: %#v, %#v", req, got) + } +} + +func TestExtensionBTRequestInitializesNilExtra(t *testing.T) { + vm := goja.New() + vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) + req := &base.Request{} + task := &Task{Protocol: "bt", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}} + if err := vm.Set("task", newOnStartExtensionTask(task)); err != nil { + t.Fatal(err) + } + if _, err := vm.RunString(`task.meta.req.setTrackers(['udp://example.com']);`); err != nil { + t.Fatal(err) + } + got, ok := req.Extra.(*btProtocol.ReqExtra) + if !ok || !reflect.DeepEqual(got.Trackers, []string{"udp://example.com"}) { + t.Fatalf("extra = %#v", req.Extra) + } +} diff --git a/pkg/download/extension_extra_test.go b/pkg/download/extension_extra_test.go new file mode 100644 index 00000000..d298946a --- /dev/null +++ b/pkg/download/extension_extra_test.go @@ -0,0 +1,104 @@ +package download + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/GopeedLab/gopeed/pkg/base" + httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http" +) + +func TestExtensionExtraMutationHTTP(t *testing.T) { + for _, resolved := range []bool{false, true} { + for _, invalid := range []bool{false, true} { + t.Run(fmt.Sprintf("resolved=%v/invalid=%v", resolved, invalid), func(t *testing.T) { + var updatedRequests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-New") == "new" { + updatedRequests.Add(1) + if r.Header.Get("X-Old") != "" { + t.Error("extra mutation retained the old header") + } + } + http.ServeContent(w, r, "test.txt", time.Time{}, strings.NewReader("extra mutation payload")) + })) + defer server.Close() + d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir()}) + if err := d.Setup(); err != nil { + t.Fatal(err) + } + defer d.Clear() + extDir := t.TempDir() + manifest := `{"name":"http-methods","title":"HTTP methods test","version":"0.0.1","scripts":[{"event":"onStart","match":{"labels":["test"]},"entry":"index.js"}]}` + mutation := `await ctx.task.meta.req.setMethod('GET'); + await ctx.task.meta.req.setBody(''); + await ctx.task.meta.req.delHeader('x-old'); + await ctx.task.meta.req.putHeader('X-New', 'new');` + if invalid { + mutation = `try { await ctx.task.meta.req.setHeaders({'X-New':123}); } + catch (err) { await ctx.task.meta.req.putLabel('rejected','true'); } ` + mutation + } + script := `gopeed.events.onStart(async ctx => { ` + mutation + ` });` + for name, content := range map[string]string{"manifest.json": manifest, "index.js": script} { + if err := os.WriteFile(filepath.Join(extDir, name), []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + if _, err := d.InstallExtensionByFolder(extDir, false); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + d.Listener(func(event *Event) { + if event.Key == EventKeyFinally { + done <- event.Err + } + }) + req := &base.Request{URL: server.URL + "/test.txt", Labels: map[string]string{"test": "true"}, Extra: &httpProtocol.ReqExtra{Header: map[string]string{"X-Old": "old"}}} + opts := &base.Options{Path: t.TempDir()} + var id string + var err error + if resolved { + rr, resolveErr := d.Resolve(req, opts) + if resolveErr != nil { + t.Fatal(resolveErr) + } + id, err = d.Create(rr.ID) + } else { + id, err = d.CreateDirect(req, opts) + } + if err != nil { + t.Fatal(err) + } + select { + case err = <-done: + case <-time.After(10 * time.Second): + t.Fatal("download timed out") + } + if invalid && d.GetTask(id).Meta.Req.Labels["rejected"] != "true" { + t.Fatal("invalid header was not rejected") + } + if err != nil { + t.Fatal(err) + } + got, ok := d.GetTask(id).Meta.Req.Extra.(*httpProtocol.ReqExtra) + if !ok || got.Header["X-New"] != "new" || len(got.Header) != 1 { + t.Fatalf("unexpected protocol extra: %#v", d.GetTask(id).Meta.Req.Extra) + } + if !resolved && updatedRequests.Load() == 0 { + t.Fatal("download did not use the extension headers") + } + data, err := os.ReadFile(filepath.Join(opts.Path, "test.txt")) + if err != nil || string(data) != "extra mutation payload" { + t.Fatalf("download content = %q, error = %v", data, err) + } + }) + } + } +} diff --git a/pkg/download/extension_host.go b/pkg/download/extension_host.go new file mode 100644 index 00000000..8fbf6fba --- /dev/null +++ b/pkg/download/extension_host.go @@ -0,0 +1,25 @@ +package download + +import ( + "runtime" + + "github.com/GopeedLab/gopeed/pkg/base" +) + +type InstanceHost struct { + Env HostEnv `json:"env"` +} + +type HostEnv struct { + Version string `json:"version"` + OS string `json:"os"` + Arch string `json:"arch"` +} + +func newInstanceHost() *InstanceHost { + return &InstanceHost{Env: HostEnv{ + Version: base.Version, + OS: runtime.GOOS, + Arch: runtime.GOARCH, + }} +} diff --git a/pkg/download/extension_runtime_events_test.go b/pkg/download/extension_runtime_events_test.go index 6d5509bd..11fbe318 100644 --- a/pkg/download/extension_runtime_events_test.go +++ b/pkg/download/extension_runtime_events_test.go @@ -5,6 +5,7 @@ import ( "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/download/engine" wv "github.com/GopeedLab/gopeed/pkg/download/engine/webview" + "runtime" "testing" "time" ) @@ -23,17 +24,21 @@ type eventTestOpener struct{ page wv.Page } func (o eventTestOpener) Open(wv.OpenOptions) (wv.Page, error) { return o.page, nil } -func TestExtensionInfoHostVersion(t *testing.T) { +func TestExtensionHostEnv(t *testing.T) { e := engine.NewEngine(nil) defer e.Close() info := NewExtensionInfo(&Extension{Name: "sample", Author: "test", Version: "1.2.3"}) if err := injectGopeed(e.Runtime, &Instance{Info: info}, e.Post); err != nil { t.Fatal(err) } - result, err := e.RunString(`gopeed.info.hostVersion + "/" + gopeed.info.version`) - if err != nil || result != base.Version+"/1.2.3" { + result, err := e.RunString(`gopeed.host.env.version + "/" + gopeed.info.version + "/" + gopeed.host.env.os + "/" + gopeed.host.env.arch`) + if err != nil || result != base.Version+"/1.2.3/"+runtime.GOOS+"/"+runtime.GOARCH { t.Fatalf("%v %v", result, err) } + result, err = e.RunString(`["hostVersion", "os", "arch"].some(key => key in gopeed.info)`) + if err != nil || result != false { + t.Fatalf("host metadata retained in extension info: %v %v", result, err) + } } func TestExtensionWebViewEvents(t *testing.T) { diff --git a/pkg/download/extension_runtime_webview.go b/pkg/download/extension_runtime_webview.go index 32c22e60..c4d9150b 100644 --- a/pkg/download/extension_runtime_webview.go +++ b/pkg/download/extension_runtime_webview.go @@ -16,6 +16,9 @@ func injectGopeed(vm *goja.Runtime, gopeed *Instance, post func(func(*goja.Runti if err := gopeedObject.Set("events", newJSEventsRuntime(vm, gopeed.Events)); err != nil { return err } + if err := gopeedObject.Set("host", newInstanceHost()); err != nil { + return err + } if err := gopeedObject.Set("info", gopeed.Info); err != nil { return err } diff --git a/pkg/download/extension_task_request.go b/pkg/download/extension_task_request.go new file mode 100644 index 00000000..2e26ce97 --- /dev/null +++ b/pkg/download/extension_task_request.go @@ -0,0 +1,140 @@ +package download + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/GopeedLab/gopeed/pkg/base" + btProtocol "github.com/GopeedLab/gopeed/pkg/protocol/bt" + httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http" + "github.com/GopeedLab/gopeed/pkg/util" + "github.com/dop251/goja" +) + +// ExtensionTaskRequest exposes request operations without exposing the backing task. +// It always reads the current request so a retained handle sees subsequent updates. +type ExtensionTaskRequest struct { + *base.Request + task *Task +} + +// SetUrl replaces the task request URL. +func (r *ExtensionTaskRequest) SetUrl(url string) { r.task.Meta.Req.URL = url } + +// SetLabels replaces all labels on the request. +func (r *ExtensionTaskRequest) SetLabels(labels map[string]string) { + r.Labels = labels +} + +// PutLabel sets a label on the request. +func (r *ExtensionTaskRequest) PutLabel(key, value string) { + if r.Labels == nil { + r.Labels = make(map[string]string) + } + r.Labels[key] = value +} + +// DelLabel deletes a label from the request. +func (r *ExtensionTaskRequest) DelLabel(key string) { + delete(r.Labels, key) +} + +// mutateTaskExtra converts into an independent protocol value before mutation. +// Other protocols are a no-op. Failed conversion or mutation must not change the live request. +func mutateTaskExtra[E any](task *Task, protocol string, mutate func(*E) error) error { + if task.Protocol != protocol { + return nil + } + if task.Meta == nil || task.Meta.Req == nil { + return fmt.Errorf("task request is missing") + } + var extra E + if err := util.MapToStruct(task.Meta.Req.Extra, &extra); err != nil { + return err + } + if err := mutate(&extra); err != nil { + return err + } + task.Meta.Req.Extra = &extra + return nil +} + +func (t *ExtensionTaskRequest) SetMethod(method string) error { + return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { extra.Method = method; return nil }) +} + +func (t *ExtensionTaskRequest) SetBody(body string) error { + return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { extra.Body = body; return nil }) +} + +// decodeHeaders omits undefined properties and rejects non-string values. +func decodeHeaders(value *goja.Object) (map[string]string, error) { + if value == nil { + return nil, fmt.Errorf("headers must be an object") + } + data, err := value.MarshalJSON() + if err != nil { + return nil, err + } + var input map[string]*string + if err := json.Unmarshal(data, &input); err != nil { + return nil, err + } + if input == nil { + return nil, fmt.Errorf("headers must be an object") + } + headers := make(map[string]string, len(input)) + seen := make(map[string]bool, len(input)) + for name, value := range input { + if value == nil { + return nil, fmt.Errorf("header %q must be a string", name) + } + lower := strings.ToLower(name) + if seen[lower] { + return nil, fmt.Errorf("duplicate header name %q", name) + } + seen[lower] = true + headers[http.CanonicalHeaderKey(name)] = *value + } + return headers, nil +} + +func (t *ExtensionTaskRequest) SetHeaders(headers *goja.Object) error { + return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { + values, err := decodeHeaders(headers) + if err != nil { + return err + } + extra.Header = values + return nil + }) +} + +func (t *ExtensionTaskRequest) PutHeader(name, value string) error { + return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { + if extra.Header == nil { + extra.Header = make(map[string]string) + } + deleteHeader(extra.Header, name) + extra.Header[http.CanonicalHeaderKey(name)] = value + return nil + }) +} + +func (t *ExtensionTaskRequest) DelHeader(name string) error { + return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { deleteHeader(extra.Header, name); return nil }) +} + +func deleteHeader(headers map[string]string, name string) { + for key := range headers { + if strings.EqualFold(key, name) { + delete(headers, key) + } + } +} + +func (t *ExtensionTaskRequest) SetTrackers(trackers []string) error { + return mutateTaskExtra(t.task, "bt", func(extra *btProtocol.ReqExtra) error { extra.Trackers = append([]string{}, trackers...); return nil }) +} diff --git a/pkg/download/extension_test.go b/pkg/download/extension_test.go index ef5ff8bd..e1f81ab3 100644 --- a/pkg/download/extension_test.go +++ b/pkg/download/extension_test.go @@ -14,10 +14,12 @@ import ( "testing" "time" + "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/internal/logger" "github.com/GopeedLab/gopeed/pkg/base" gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error" enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" + httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http" "github.com/dop251/goja" ) @@ -38,13 +40,13 @@ func TestDownloader_InstallExtensionByFolder(t *testing.T) { t.Fatal("resolve error") } if req.Labels["replaced"] != "true" || req.Labels["modified"] != "true" { - t.Fatalf("request label methods did not update labels: %#v", req.Labels) + t.Fatalf("declarative labels did not update: %#v", req.Labels) } if _, ok := req.Labels["original"]; ok { - t.Fatalf("setLabels did not replace existing labels: %#v", req.Labels) + t.Fatalf("label assignment did not replace existing labels: %#v", req.Labels) } if _, ok := req.Labels["removed"]; ok { - t.Fatalf("delLabel did not remove label: %#v", req.Labels) + t.Fatalf("declarative label deletion failed: %#v", req.Labels) } }) } @@ -1188,6 +1190,37 @@ func TestDownloader_UpgradeExtension(t *testing.T) { }) } +func TestDownloader_Extension_OnStartExtraMutation(t *testing.T) { + d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir()}) + if err := d.Setup(); err != nil { + t.Fatal(err) + } + defer d.Clear() + if _, err := d.InstallExtensionByFolder("./testdata/extensions/on_start", false); err != nil { + t.Fatal(err) + } + for _, extra := range []any{nil, &httpProtocol.ReqExtra{ + Method: "POST", Body: "old body", Header: map[string]string{"Old": "value"}, + }} { + req := &base.Request{URL: "https://github.com/test", Extra: extra} + task := &Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}} + d.triggerOnStart(task) + if err := base.ParseReqExtra[httpProtocol.ReqExtra](req); err != nil { + t.Fatal(err) + } + got, ok := req.Extra.(*httpProtocol.ReqExtra) + if !ok || got.Header["X-Gopeed-Test"] != "on-start" { + t.Fatalf("setHeaders did not update the original request: %#v", req.Extra) + } + if len(got.Header) != 1 { + t.Fatalf("headers not replaced: %#v", got) + } + if original, ok := extra.(*httpProtocol.ReqExtra); ok && (got.Method != original.Method || got.Body != original.Body) { + t.Fatalf("setHeaders changed unrelated fields: %#v", got) + } + } +} + func TestDownloader_Extension_OnStart(t *testing.T) { downloadAndCheck := func(req *base.Request) { setupDownloader(func(downloader *Downloader) { diff --git a/pkg/download/testdata/extensions/basic/index.js b/pkg/download/testdata/extensions/basic/index.js index cfbde77b..eae932f4 100644 --- a/pkg/download/testdata/extensions/basic/index.js +++ b/pkg/download/testdata/extensions/basic/index.js @@ -1,7 +1,10 @@ gopeed.events.onResolve(async function (ctx) { - await ctx.req.setLabels({ replaced: "true", removed: "true" }); - await ctx.req.putLabel("modified", "true"); - await ctx.req.delLabel("removed"); + for (const method of ['setUrl', 'setLabels', 'putLabel', 'delLabel', 'setMethod', 'putHeader', 'setTrackers']) { + if (typeof ctx.req[method] !== 'undefined') throw new Error(`onResolve exposes ${method}`); + } + ctx.req.labels = { replaced: "true", removed: "true" }; + ctx.req.labels.modified = "true"; + delete ctx.req.labels.removed; ctx.res = { name: "test", files: Array(2).fill(true).map((_, i) => ({ diff --git a/pkg/download/testdata/extensions/blob_pause_rebuild/index.js b/pkg/download/testdata/extensions/blob_pause_rebuild/index.js index 4b3fbabc..d3d80520 100644 --- a/pkg/download/testdata/extensions/blob_pause_rebuild/index.js +++ b/pkg/download/testdata/extensions/blob_pause_rebuild/index.js @@ -132,7 +132,7 @@ gopeed.events.onError(async function (ctx) { return; } - await ctx.task.setUrl(await createPayloadURL(source, false)); + await ctx.task.meta.req.setUrl(await createPayloadURL(source, false)); await req.putLabel('started', 'true'); await req.putLabel('rebuilt', 'true'); await ctx.task.continue(); diff --git a/pkg/download/testdata/extensions/blob_recover/index.js b/pkg/download/testdata/extensions/blob_recover/index.js index 7a6c4926..c137bc0d 100644 --- a/pkg/download/testdata/extensions/blob_recover/index.js +++ b/pkg/download/testdata/extensions/blob_recover/index.js @@ -41,6 +41,6 @@ gopeed.events.onError(async function (ctx) { } await req.putLabel("recovered", "true"); - await ctx.task.setUrl(await gopeed.runtime.blob.createObjectURL(new Blob(["ok\n"], { type: "text/plain" }))); + await ctx.task.meta.req.setUrl(await gopeed.runtime.blob.createObjectURL(new Blob(["ok\n"], { type: "text/plain" }))); await ctx.task.continue(); }); diff --git a/pkg/download/testdata/extensions/blob_restart/index.js b/pkg/download/testdata/extensions/blob_restart/index.js index b640f674..57cac8a0 100644 --- a/pkg/download/testdata/extensions/blob_restart/index.js +++ b/pkg/download/testdata/extensions/blob_restart/index.js @@ -46,7 +46,7 @@ gopeed.events.onError(async function (ctx) { } try { - await ctx.task.setUrl(await createRangeBlobUrl(req.rawUrl)); + await ctx.task.meta.req.setUrl(await createRangeBlobUrl(req.rawUrl)); await req.putLabel('started', 'true'); await req.putLabel('rebuilt', 'true'); await ctx.task.continue(); diff --git a/pkg/download/testdata/extensions/on_error/index.js b/pkg/download/testdata/extensions/on_error/index.js index e5fa2955..3dde4371 100644 --- a/pkg/download/testdata/extensions/on_error/index.js +++ b/pkg/download/testdata/extensions/on_error/index.js @@ -1,6 +1,6 @@ gopeed.events.onError(async function (ctx) { gopeed.logger.info("url", ctx.task.meta.req.url); gopeed.logger.info("error", ctx.error); - await ctx.task.setUrl("https://github.com"); + await ctx.task.meta.req.setUrl("https://github.com"); await ctx.task.continue(); }); diff --git a/pkg/download/testdata/extensions/on_start/index.js b/pkg/download/testdata/extensions/on_start/index.js index 59370be3..a882db24 100644 --- a/pkg/download/testdata/extensions/on_start/index.js +++ b/pkg/download/testdata/extensions/on_start/index.js @@ -1,5 +1,6 @@ gopeed.events.onStart(async function (ctx) { gopeed.logger.info("url", ctx.task.meta.req.url); - await ctx.task.setUrl("https://github.com"); + await ctx.task.meta.req.setUrl("https://github.com"); + await ctx.task.meta.req.setHeaders({ "X-Gopeed-Test": "on-start" }); await ctx.task.meta.req.putLabel('modified', 'true'); }); diff --git a/ui/flutter/ios/Runner.xcodeproj/project.pbxproj b/ui/flutter/ios/Runner.xcodeproj/project.pbxproj index 677ed142..691bb2e9 100644 --- a/ui/flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/ui/flutter/ios/Runner.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 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 */; }; + B30000000000000000000001 /* GopeedTaskEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B30000000000000000000002 /* GopeedTaskEvent.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 */; }; @@ -102,6 +103,7 @@ 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 = ""; }; + B30000000000000000000002 /* GopeedTaskEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedTaskEvent.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 = ""; }; @@ -286,6 +288,7 @@ 09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */, EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */, 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */, + B30000000000000000000002 /* GopeedTaskEvent.swift */, ); path = Runner; sourceTree = ""; @@ -607,6 +610,7 @@ 1E29080079AAFD1CF2C71262 /* GopeedDownloadAttributes.swift in Sources */, 032447D610CFAF8A75642212 /* GopeedLiveActivityManager.swift in Sources */, 5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */, + B30000000000000000000001 /* GopeedTaskEvent.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift b/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift index bc8f65a9..39453e06 100644 --- a/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift +++ b/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift @@ -22,8 +22,8 @@ final class GopeedContinuedProcessingManager: NSObject { // MARK: - BGCPT state - private var registeredIdentifiers = Set() private var pendingTaskIDs = Set() + private var foregroundStartTokens: [String: UUID] = [:] private struct ExpiringTaskState { let identifier: String let generation: UUID @@ -167,16 +167,11 @@ final class GopeedContinuedProcessingManager: NSObject { // 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 + let event = GopeedTaskEvent.decode(payload), + event.type == .start { setTaskOwnership( - taskID: taskID, + taskID: event.taskID, handled: true ) } @@ -198,33 +193,19 @@ final class GopeedContinuedProcessingManager: NSObject { 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 { + guard let event = GopeedTaskEvent.decode(payload) else { print( "ContinuedProcessing: invalid event" ) return } - let name = - json["name"] as? String - ?? "Download" + let taskID = event.taskID + let name = event.name - switch type { + switch event.type { - case "task.start": + case .start: progressRecoveryTokens.removeValue( forKey: taskID ) @@ -234,7 +215,7 @@ final class GopeedContinuedProcessingManager: NSObject { name: name ) - case "task.progress": + case .progress: if let generation = taskGenerations[taskID] { updateProgress( @@ -249,7 +230,7 @@ final class GopeedContinuedProcessingManager: NSObject { ) } - case "task.pause": + case .pause: if let expiring = expiringTasks[taskID] { finishExpirationCleanup( @@ -265,7 +246,7 @@ final class GopeedContinuedProcessingManager: NSObject { ) } - case "task.done": + case .done: finishTask( taskID: taskID, success: true, @@ -273,7 +254,7 @@ final class GopeedContinuedProcessingManager: NSObject { "Download complete" ) - case "task.error": + case .error: finishTask( taskID: taskID, success: false, @@ -281,7 +262,7 @@ final class GopeedContinuedProcessingManager: NSObject { "Download failed" ) - case "task.delete": + case .delete: finishTask( taskID: taskID, success: true, @@ -289,12 +270,11 @@ final class GopeedContinuedProcessingManager: NSObject { "Download removed" ) - default: + case .other: break } } - // MARK: - Recover active task after setting changes private func recoverTaskFromProgress( @@ -307,6 +287,7 @@ final class GopeedContinuedProcessingManager: NSObject { !pendingTaskIDs.contains(taskID), activeTasks[taskID] == nil, expiringTasks[taskID] == nil, + foregroundStartTokens[taskID] == nil, progressRecoveryTokens[taskID] == nil else { return @@ -395,7 +376,8 @@ final class GopeedContinuedProcessingManager: NSObject { guard !pendingTaskIDs.contains(taskID), - activeTasks[taskID] == nil + activeTasks[taskID] == nil, + foregroundStartTokens[taskID] == nil else { setTaskOwnership( taskID: taskID, @@ -404,20 +386,57 @@ final class GopeedContinuedProcessingManager: NSObject { return } - // BGCPT should originate from a foreground user action. - // This main-thread check happens only once per task start. - let appIsActive: Bool + // BGCPT must originate from a foreground user action. + // Never synchronously hop from workerQueue to the main queue: + // doing so can deadlock if the main thread is waiting on work + // that eventually needs workerQueue. + let foregroundToken = UUID() + foregroundStartTokens[taskID] = + foregroundToken - if Thread.isMainThread { - appIsActive = + DispatchQueue.main.async { [weak self] in + let appIsActive = UIApplication.shared .applicationState == .active - } else { - appIsActive = - DispatchQueue.main.sync { - UIApplication.shared - .applicationState == .active - } + + guard let self else { + return + } + + self.workerQueue.async { + self.continueBeginTaskAfterForegroundCheck( + taskID: taskID, + name: name, + foregroundToken: foregroundToken, + appIsActive: appIsActive + ) + } + } + } + + private func continueBeginTaskAfterForegroundCheck( + taskID: String, + name: String, + foregroundToken: UUID, + appIsActive: Bool + ) { + guard + foregroundStartTokens[taskID] + == foregroundToken + else { + return + } + + foregroundStartTokens.removeValue( + forKey: taskID + ) + + guard currentEnabledSnapshot() else { + setTaskOwnership( + taskID: taskID, + handled: false + ) + return } guard appIsActive else { @@ -434,6 +453,20 @@ final class GopeedContinuedProcessingManager: NSObject { return } + // Re-check after the asynchronous main-thread hop. A terminal + // event, disable, or another valid start may have changed the + // lifecycle while the foreground state was being read. + guard + !pendingTaskIDs.contains(taskID), + activeTasks[taskID] == nil + else { + setTaskOwnership( + taskID: taskID, + handled: true + ) + return + } + let generation = UUID() taskGenerations[taskID] = generation @@ -498,8 +531,6 @@ final class GopeedContinuedProcessingManager: NSObject { return } - registeredIdentifiers.insert(identifier) - let request = BGContinuedProcessingTaskRequest( identifier: identifier, @@ -558,7 +589,6 @@ final class GopeedContinuedProcessingManager: NSObject { } } - // MARK: - System launched task private func activateTask( @@ -901,6 +931,10 @@ final class GopeedContinuedProcessingManager: NSObject { success: Bool, finalSubtitle: String ) { + foregroundStartTokens.removeValue( + forKey: taskID + ) + progressRecoveryTokens.removeValue( forKey: taskID ) @@ -1115,6 +1149,7 @@ final class GopeedContinuedProcessingManager: NSObject { Set( handledTaskIDsSnapshot() + Array(taskIdentifiers.keys) + + Array(foregroundStartTokens.keys) + Array(pendingTaskIDs) + Array(activeTasks.keys) + Array(expiringTasks.keys) @@ -1140,6 +1175,7 @@ final class GopeedContinuedProcessingManager: NSObject { } activeTasks.removeAll() + foregroundStartTokens.removeAll() pendingTaskIDs.removeAll() expiringTasks.removeAll() taskGenerations.removeAll() diff --git a/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift b/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift index 6889dec9..1fc9fa65 100644 --- a/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift +++ b/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift @@ -248,26 +248,22 @@ final class GopeedLiveActivityManager: NSObject { 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 { + guard let event = GopeedTaskEvent.decode(payload) else { print("LiveActivity: invalid task event payload") return } + let taskID = event.taskID + guard !isTaskSuppressed(taskID) else { return } - let name = json["name"] as? String ?? "Download" + let name = event.name - switch type { + switch event.type { - case "task.start": + case .start: let generation = startGeneration( taskID: taskID @@ -282,7 +278,7 @@ final class GopeedLiveActivityManager: NSObject { ) } - case "task.progress": + case .progress: guard let generation = generationForProgress( @@ -298,7 +294,7 @@ final class GopeedLiveActivityManager: NSObject { generation: generation ) - case "task.pause": + case .pause: if let generation = invalidateGeneration( taskID: taskID @@ -312,7 +308,7 @@ final class GopeedLiveActivityManager: NSObject { } } - case "task.done": + case .done: if let generation = invalidateGeneration( taskID: taskID @@ -325,9 +321,9 @@ final class GopeedLiveActivityManager: NSObject { } } - case "task.error": + case .error: let error = - json["error"] as? String + event.error ?? "Download failed" if let generation = @@ -343,7 +339,7 @@ final class GopeedLiveActivityManager: NSObject { } } - case "task.delete": + case .delete: if let generation = invalidateGeneration( taskID: taskID @@ -357,7 +353,7 @@ final class GopeedLiveActivityManager: NSObject { } } - default: + case .other: break } } diff --git a/ui/flutter/ios/Runner/GopeedTaskEvent.swift b/ui/flutter/ios/Runner/GopeedTaskEvent.swift new file mode 100644 index 00000000..613435d8 --- /dev/null +++ b/ui/flutter/ios/Runner/GopeedTaskEvent.swift @@ -0,0 +1,69 @@ +import Foundation + +enum GopeedTaskEventType: Equatable { + case start + case progress + case pause + case done + case error + case delete + case other(String) + + init(rawValue: String) { + switch rawValue { + case "task.start": + self = .start + case "task.progress": + self = .progress + case "task.pause": + self = .pause + case "task.done": + self = .done + case "task.error": + self = .error + case "task.delete": + self = .delete + default: + self = .other(rawValue) + } + } +} + +struct GopeedTaskEvent { + let type: GopeedTaskEventType + let taskID: String + let name: String + let error: String? + + private struct Payload: Decodable { + let type: String + let taskId: String + let name: String? + let error: String? + } + + static func decode( + _ payload: String + ) -> GopeedTaskEvent? { + guard + let data = payload.data(using: .utf8), + let decoded = + try? JSONDecoder().decode( + Payload.self, + from: data + ) + else { + return nil + } + + return GopeedTaskEvent( + type: + GopeedTaskEventType( + rawValue: decoded.type + ), + taskID: decoded.taskId, + name: decoded.name ?? "Download", + error: decoded.error + ) + } +} diff --git a/ui/flutter/lib/l10n/app_sk.arb b/ui/flutter/lib/l10n/app_sk.arb new file mode 100644 index 00000000..1a7b5cae --- /dev/null +++ b/ui/flutter/lib/l10n/app_sk.arb @@ -0,0 +1,652 @@ +{ + "@@locale": "sk", + "label": "Slovenčina", + "error": "Chyba", + "tip": "Tip", + "confirm": "Potvrdiť", + "confirmDelete": "Naozaj chcete odstrániť?", + "cancel": "Zrušiť", + "on": "Zapnuté", + "off": "Vypnuté", + "selectAll": "Vybrať všetko", + "select": "Vybrať", + "task": "Úlohy", + "downloading": "Sťahovanie", + "downloaded": "Stiahnuté", + "setting": "Nastavenia", + "donate": "Podpora", + "exit": "Ukončiť", + "pressBackAgainToExit": "Pre ukončenie stlačte späť ešte raz", + "create": "Vytvoriť úlohu", + "createAction": "Vytvoriť", + "directDownload": "Priame sťahovanie", + "advancedOptions": "Rozšírené možnosti", + "followSettings": "Podľa nastavení", + "downloadLink": "Odkaz na stiahnutie", + "downloadLinkValid": "Zadajte odkaz na stiahnutie", + "downloadLinkHit": "Zadajte odkaz na stiahnutie, jeden na riadok@append", + "downloadLinkHitDesktop": ", alebo sem presuňte torrent súbor", + "download": "Stiahnuť", + "noFileSelected": "Pokračujte výberom aspoň jedného súboru", + "noStoragePermission": "Vyžaduje sa povolenie na ukladanie", + "selectFile": "Vybrať súbor", + "rename": "Premenovať", + "basic": "Základné", + "advanced": "Rozšírené", + "general": "Všeobecné", + "downloadDir": "Priečinok na stiahnutie", + "rememberLastDownloadDir": "Uložiť ako predvolený priečinok na stiahnutie", + "downloadDirValid": "Vyberte priečinok na stiahnutie", + "connections": "Pripojenia", + "maxRetries": "Maximálny počet pokusov", + "useServerCtime": "Použiť čas servera na vytvorenie súboru", + "maxRunning": "Súbežné sťahovania", + "defaultDirectDownload": "Predvolene priame sťahovanie", + "autoStartTasks": "Automaticky spustiť nedokončené úlohy pri štarte", + "autoTorrentEnable": "Automaticky vytvárať BT úlohy z .torrent súborov", + "autoTorrentDeleteAfterDownload": "Po vytvorení BT úlohy odstrániť .torrent súbor", + "autoDeleteMissingFileTasks": "Automaticky odstrániť úlohy s chýbajúcimi súbormi", + "items": "{count} položiek", + "@items": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "subscribeTracker": "Odoberať tracker", + "subscribeFail": "Odber zlyhal, skontrolujte sieť alebo to skúste neskôr", + "update": "Aktualizovať", + "updateDaily": "Aktualizovať denne", + "lastUpdate": "Posledná aktualizácia: @time", + "addTracker": "Pridať tracker", + "addTrackerHit": "Zadajte URL tracker servera, jedna na riadok", + "ui": "Rozhranie", + "theme": "Téma", + "themeSystem": "Systémová", + "themeLight": "Svetlá", + "themeDark": "Tmavá", + "locale": "Jazyk", + "notifyWhenNewVersion": "Upozorniť na aktualizácie", + "analyticsEnabled": "Analytika používania", + "analyticsEnabledDesc": "Zdieľajte anonymné údaje o používaní a pomôžte nám zlepšovať sa", + "about": "O aplikácii", + "homepage": "Domovská stránka", + "version": "Verzia", + "protocol": "Protokol", + "port": "Port", + "apiToken": "API token", + "notSet": "NS", + "set": "SET", + "portInUse": "Port [@port] sa používa, zmeňte port", + "effectAfterRestart": "Účinné po reštarte", + "developer": "Vývojár", + "logDirectory": "Priečinok protokolov", + "webhook": "Webhook", + "webhookEnable": "Povoliť webhooky", + "webhookDesc": "Odosielať HTTP POST upozornenia po dokončení alebo zlyhaní úloh", + "webhookUrlHint": "Zadajte URL webhooku", + "webhookTest": "Test", + "webhookTestSuccess": "Test webhooku úspešný", + "webhookTestFail": "Test webhooku zlyhal", + "script": "Skript", + "scriptEnable": "Povoliť skripty", + "scriptDesc": "Spustiť vlastné skripty po úspešnom dokončení sťahovania", + "scriptPathHint": "Zadajte cestu k súboru skriptu (napr. /path/to/script.sh)", + "urlInvalid": "Zadajte platnú HTTP alebo HTTPS URL", + "required": "Toto pole je povinné", + "show": "Zobraziť", + "continueAction": "Pokračovať", + "pause": "Pozastaviť", + "startAll": "Spustiť všetko", + "pauseAll": "Pozastaviť všetko", + "deleteTask": "Odstrániť @count úloh", + "deleteTaskTip": "Zachovať stiahnuté súbory", + "delete": "Odstrániť", + "add": "Pridať", + "edit": "Upraviť", + "newVersionTitle": "Objavená nová verzia @version", + "newVersionUpdate": "Aktualizovať teraz", + "newVersionLater": "Neskôr", + "extensions": "Rozšírenia", + "extensionInstallUrl": "Nainštalovať z URL", + "extensionInstallSuccess": "Úspešne nainštalované", + "extensionUpdateConfirm": "Aktualizovať „{name}“ na najnovšiu verziu?", + "@extensionUpdateConfirm": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "extensionUpdateSuccess": "Úspešne aktualizované", + "extensionDelete": "Odstrániť rozšírenie", + "extensionAlreadyLatest": "Už je najnovšia verzia", + "extensionFind": "Nájsť rozšírenia", + "extensionDevelop": "Vyvíjať rozšírenia", + "extensionStoreTitle": "Obchod s rozšíreniami", + "extensionStoreSection": "Rozšírenia z obchodu", + "extensionInstalledSection": "Nainštalované rozšírenia", + "extensionInstalledEmpty": "Zatiaľ nie sú nainštalované žiadne rozšírenia", + "extensionStoreEmpty": "Nenašli sa žiadne zodpovedajúce rozšírenia", + "extensionSearchHint": "Hľadať podľa názvu, titulku, popisu alebo autora", + "extensionSortStars": "Najviac hviezd", + "extensionSortInstalls": "Najviac inštalácií", + "extensionSortUpdated": "Naposledy aktualizované", + "extensionSortToggle": "Prepnúť smer zoradenia", + "extensionLoadMore": "Načítať viac", + "extensionNoMore": "Žiadne ďalšie rozšírenia", + "extensionInstalled": "Nainštalované", + "extensionCanUpdate": "Dostupná aktualizácia", + "extensionInstall": "Nainštalovať", + "extensionLoadLocal": "Nainštalovať z lokálneho priečinka", + "extensionFilterAll": "Všetky", + "extensionFilterMarket": "Obchod", + "extensionFilterInstalled": "Nainštalované", + "extensionManualInstall": "Manuálna inštalácia", + "extensionInstallTools": "Možnosti inštalácie", + "history": "História", + "clearHistory": "Vymazať históriu", + "noHistoryFound": "História sa nenašla", + "serviceTitle": "Služba sťahovania", + "serviceText": "Spustená", + "network": "Sieť", + "proxy": "Proxy", + "noProxy": "Bez proxy", + "systemProxy": "Systémová proxy", + "customProxy": "Vlastná proxy", + "server": "Server", + "username": "Používateľské meno", + "password": "Heslo", + "thanks": "Poďakovanie", + "thanksDesc": "Ďakujeme všetkým prispievateľom, ktorí pomohli budovať a rozvíjať komunitu Gopeed!", + "browserExtension": "Rozšírenie prehliadača", + "launchAtStartup": "Spustiť pri štarte", + "runAsMenubarApp": "Spustiť ako aplikáciu v paneli ponuky", + "runAsMenubarAppDesc": "Skryť ikonu v docku a spustiť iba v paneli ponuky", + "seedConfig": "Konfigurácia seedovania", + "seedKeep": "Pokračovať v seedovaní až do manuálneho zastavenia", + "seedRatio": "Pomer seedovania", + "seedTime": "Čas seedovania (minúty)", + "setAsDefaultBtClient": "Nastaviť ako predvoleného BT klienta", + "taskDetail": "Detail úlohy", + "taskName": "Názov úlohy", + "taskUrl": "URL úlohy", + "downloadPath": "Cesta stiahnutia", + "skipVerifyCert": "Preskočiť overenie certifikátu", + "archives": "Archívy", + "autoExtract": "Automaticky rozbaliť archívy", + "archivePassword": "Heslo archívu", + "archivePasswordHint": "Nechajte prázdne, ak nie je chránený heslom", + "deleteAfterExtract": "Po rozbalení odstrániť archívy", + "extracting": "Rozbaľovanie", + "extractDone": "Rozbalenie dokončené", + "extractError": "Rozbalenie zlyhalo", + "waitingParts": "Čakanie na časti", + "name": "Názov", + "size": "Veľkosť", + "unknown": "Neznáme", + "fileSelectedCount": "Súbory: ", + "fileSelectedSize": "Veľkosť: ", + "httpHeader": "Hlavička", + "httpHeaderName": "Názov hlavičky", + "httpHeaderValue": "Hodnota hlavičky", + "login": "Prihlásenie", + "username_required": "Zadajte používateľské meno", + "password_required": "Zadajte heslo", + "login_success": "Prihlásenie úspešné", + "login_failed": "Prihlásenie zlyhalo, skontrolujte používateľské meno a heslo", + "login_failed_network": "Prihlásenie zlyhalo, skontrolujte sieťové pripojenie", + "insertPlaceholder": "Vložiť zástupný znak", + "placeholderYear": "Aktuálny rok", + "placeholderMonth": "Aktuálny mesiac (01-12)", + "placeholderDay": "Aktuálny deň (01-31)", + "placeholderDate": "Celý dátum (RRRR-MM-DD)", + "example": "napr. @value", + "downloadCategories": "Kategórie sťahovania", + "categoryMusic": "Hudba", + "categoryVideo": "Video", + "categoryDocument": "Dokument", + "categoryProgram": "Program", + "categoryName": "Názov kategórie", + "categoryPath": "Cesta kategórie", + "builtInCategory": "Vstavanú kategóriu nie je možné odstrániť", + "selectCategory": "Vybrať kategóriu", + "quickDirectories": "Rýchle priečinky", + "githubMirror": "GitHub zrkadlo", + "githubMirrorEnable": "Povoliť GitHub zrkadlo", + "githubMirrorDesc": "Použiť zrkadlá na zrýchlenie sťahovania obsahu z GitHubu", + "githubMirrorType": "Typ zrkadla", + "githubMirrorUrl": "URL zrkadla", + "githubMirrorUrlHint": "Zadajte URL zrkadla", + "updateUrl": "Aktualizovať URL", + "updateUrlManual": "Manuálna aktualizácia", + "updateUrlListen": "Počúvať aktualizáciu", + "updateUrlListeningTip": "Čakanie na aktualizáciu URL", + "updateUrlCancelListen": "Zrušiť počúvanie", + "updateUrlDialogHint": "Zadajte novú URL na stiahnutie", + "pendingUpdateFound": "Nájdená čakajúca úloha aktualizácie", + "pendingUpdateConfirm": "Úloha „{name}“ čaká na aktualizáciu URL. Chcete ju aktualizovať novou URL?", + "@pendingUpdateConfirm": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pendingUpdateYes": "Aktualizovať úlohu", + "pendingUpdateNo": "Vytvoriť novú úlohu", + "ed2k": "ED2K", + "ed2kTcpPort": "TCP port na počúvanie", + "ed2kUdpPort": "UDP port na počúvanie", + "ed2kServerList": "Adresy serverov", + "ed2kServerMet": "Zdroje server.met", + "ed2kNodesDat": "Zdroje nodes.dat", + "ed2kAutoPort": "Automaticky priradiť", + "ed2kOnePerLine": "Jeden záznam na riadok", + "ed2kServersHint": "host:port, jeden na riadok", + "ed2kServerMetHint": "URL server.met alebo odkaz na ed2k serverlist, jeden na riadok", + "ed2kNodesDatHint": "Cesta alebo URL nodes.dat, jedna na riadok", + "desktopNotification": "Upozornenia na ploche", + "backgroundLocationKeepAlive": "Sťahovanie na pozadí", + "backgroundContinuedProcessing": "Pokračujúce spracovanie (iOS 26+)", + "notificationTaskDone": "Úloha dokončená", + "notificationTaskError": "Chyba úlohy", + "appTitle": "Gopeed správca sťahovania", + "unsupportedWindowPayload": "Nepodporovaná požiadavka okna", + "runtimeInitializationFailed": "Nepodarilo sa inicializovať runtime Gopeed.\n{error}", + "save": "Uložiť", + "retry": "Skúsiť znova", + "close": "Zavrieť", + "back": "Späť", + "open": "Otvoriť", + "openFile": "Otvoriť súbor", + "openFolder": "Otvoriť priečinok", + "share": "Zdieľať", + "copy": "Kopírovať", + "copied": "Skopírované", + "refresh": "Obnoviť", + "status": "Stav", + "createdAt": "Vytvorené", + "speed": "Rýchlosť", + "uploadSpeed": "Rýchlosť odosielania", + "remaining": "Zostávajúce", + "downloadDuration": "Trvanie sťahovania", + "progress": "Priebeh", + "failed": "Zlyhalo", + "completed": "Dokončené", + "waiting": "Čakanie", + "taskFailed": "Úloha zlyhala", + "unknownSize": "Neznáma veľkosť", + "secondsRemaining": "{count, plural, =1{zostáva 1 sekunda} other{zostáva {count} sekúnd}}", + "minutesRemaining": "{count, plural, =1{zostáva 1 minúta} other{zostáva {count} minút}}", + "hoursRemaining": "{count, plural, =1{zostáva 1 hodina} other{zostáva {count} hodín}}", + "secondsDuration": "{count, plural, =1{1 sekunda} other{{count} sekúnd}}", + "minutesDuration": "{count, plural, =1{1 minúta} other{{count} minút}}", + "hoursDuration": "{count, plural, =1{1 hodina} other{{count} hodín}}", + "followSystem": "Podľa systému", + "basicSettings": "Základné", + "downloadSettings": "Sťahovania", + "advancedSettings": "Rozšírené", + "themeColor": "Farba zvýraznenia", + "themeColorDescription": "Prispôsobiť farbu zvýraznenia aplikácie", + "browserExtensionDescription": "Po inštalácii posielať sťahovania z prehliadača priamo do Gopeed", + "desktopNotificationDescription": "Zobraziť systémové upozornenie po dokončení alebo zlyhaní úlohy", + "backgroundLocationKeepAliveDescription": "Použiť prístup k polohe na udržanie aktívnych sťahovaní na pozadí", + "backgroundContinuedProcessingDescription": "Použiť pokračujúce spracovanie iOS a jeho systémovú Live Activity na udržanie sťahovaní bez prístupu k polohe", + "backgroundContinuedProcessingUnsupported": "Pokračujúce spracovanie vyžaduje iOS 26 alebo novší", + "currentVersion": "Aktuálna verzia {version}", + "contributors": "Prispievatelia", + "viewContributors": "Zobraziť prispievateľov", + "maxRunningDescription": "Maximálny počet úloh, ktoré sa môžu súčasne sťahovať", + "defaultUserAgentDescription": "Predvolený User-Agent pre HTTP a HTTPS sťahovania", + "listenPort": "Port na počúvanie", + "onePerLine": "Jeden záznam na riadok", + "proxyProtocol": "Protokol", + "restartRequired": "Toto nastavenie sa prejaví po reštarte aplikácie", + "socketPath": "Cesta soketu", + "listenAddress": "Adresa na počúvanie", + "currentConnection": "Aktuálny koncový bod", + "webhookPush": "Koncové body", + "webhookPushDescription": "Odosielať HTTP POST upozornenia na tieto koncové body po dokončení alebo zlyhaní úloh", + "scriptExecution": "Skripty", + "scriptExecutionDescription": "Spustiť tieto skripty po úspešnom dokončení sťahovania", + "addWebhook": "Pridať webhook", + "editWebhook": "Upraviť webhook", + "addScript": "Pridať skript", + "editScript": "Upraviť skript", + "openDirectory": "Otvoriť priečinok", + "browseFiles": "Prehľadávať súbory", + "updateEveryDay": "Aktualizovať denne", + "updateVersion": "Aktualizovať", + "checkForUpdates": "Skontrolovať aktualizácie", + "updating": "Aktualizuje sa…", + "colorGreen": "Zelená", + "colorRed": "Červená", + "colorRose": "Ružová", + "colorOrange": "Oranžová", + "colorCyan": "Tyrkysová", + "colorBlue": "Modrá", + "colorAmber": "Jantárová", + "colorPurple": "Fialová", + "noExtensions": "Žiadne rozšírenia", + "searchExtensions": "Hľadať rozšírenia…", + "extensionDeveloperGuide": "Príručka vývojára rozšírení", + "extensionInstallFromUrl": "Nainštalovať rozšírenie z URL", + "unableOpenExtensionDocs": "Nepodarilo sa otvoriť príručku vývojára rozšírení", + "removeExtensionTitle": "Odinštalovať rozšírenie?", + "removeExtensionConfirm": "Naozaj chcete odinštalovať „{name}“?", + "uninstall": "Odinštalovať", + "unableLoadExtensions": "Nepodarilo sa načítať rozšírenia", + "filters": "Filtre", + "allTasks": "Všetky úlohy", + "searchDownloads": "Hľadať sťahovania…", + "searchHistory": "Hľadať v histórii…", + "addTask": "Pridať úlohu", + "emptyTaskList": "V tomto zozname nie sú žiadne úlohy", + "noMatchingTasks": "Nenašli sa žiadne zodpovedajúce úlohy", + "taskList": "Úlohy", + "selectedCount": "vybrané {selected} / {total}", + "unableOpenPath": "Nepodarilo sa otvoriť {path}", + "unableLocatePath": "Nepodarilo sa lokalizovať {path}", + "clearSelection": "Zrušiť výber", + "deselectTask": "Zrušiť výber", + "selectTask": "Vybrať", + "updateManually": "Aktualizovať manuálne", + "stopListening": "Zastaviť počúvanie", + "listenForUrl": "Počúvať URL", + "waitingForReplacementUrl": "Čakanie na náhradnú URL", + "taskNotFound": "Úloha sa nenašla", + "storagePath": "Cesta úložiska", + "directory": "Priečinok", + "mode": "Režim", + "keepOriginalName": "Zachovať pôvodný názov", + "enterCount": "Zadajte počet", + "chooseDownloadDirectory": "Vyberte priečinok na stiahnutie", + "applicationStorage": "Úložisko aplikácie", + "applicationStorageDescription": "Spoľahlivé úložisko aplikácie; súbory sa odstránia po odinštalovaní aplikácie", + "publicDownloadsDescription": "Súbory zostanú po odinštalovaní; dostupnosť závisí od zariadenia Android", + "downloadsDirectoryUnavailable": "Gopeed nemôže zapisovať do Download/Gopeed na tomto zariadení", + "enterValue": "Zadajte hodnotu", + "trackers": "Trackery", + "oneTrackerPerLine": "Jedna URL trackeru na riadok", + "pasteDownloadLinks": "Vložte HTTP, HTTPS alebo MAGNET odkazy, alebo sem presuňte torrent súbor", + "unableReadTorrent": "Nepodarilo sa prečítať torrent súbor: {error}", + "torrentOnly": "Podporované sú iba .torrent súbory", + "torrentDataEmpty": "Údaje torrent súboru sú prázdne", + "torrentPathUnavailable": "Nie je možné pristúpiť k ceste torrent súboru", + "selectAtLeastOneFile": "Vyberte aspoň jeden súbor", + "resolvedFiles": "Vyriešené súbory", + "resolvedFileCount": "{count, plural, =1{vyriešený 1 súbor} other{vyriešených {count} súborov}}", + "resourceHasNoFiles": "Zdroj nemá žiadny vyberateľný zoznam súborov", + "noFiles": "Žiadne súbory", + "historyLinks": "Odkazy v histórii", + "noHistory": "Žiadna história", + "directDownloadDescription": "Preskočiť riešenie a okamžite vytvoriť úlohy", + "taskProgress": "Priebeh úlohy", + "updateAndResume": "Aktualizovať a pokračovať", + "httpHeaders": "HTTP hlavičky", + "piece": "Časť {number}", + "pieceRange": "Časti {start}–{end}", + "connectionStatus": "Stav pripojenia", + "connectionCount": "{count, plural, =1{1 pripojenie} other{{count} pripojení}}", + "retrying": "Opätovný pokus", + "connectionFailed": "Pripojenie zlyhalo", + "connectionFailedRetries": "Pripojenie zlyhalo po {count} pokusoch", + "retryingCount": "Opätovný pokus · {count} pokusov", + "pieceStatus": "Stav častí", + "peers": "Peerovia", + "seeders": "Seedery", + "leechers": "Leecherovia", + "downloadedAmount": "Stiahnuté", + "uploadedAmount": "Odoslané", + "shareRatio": "Pomer zdieľania", + "shareDuration": "Trvanie zdieľania", + "connectedPeers": "{count} pripojených", + "active": "Aktívne", + "completedPieces": "Dokončené časti", + "activePeers": "{count} aktívnych", + "incomplete": "Nedokončené", + "address": "Adresa", + "client": "Klient", + "pieces": "Časti", + "source": "Zdroj", + "fileRelevance": "Relevancia súboru", + "taskDetails": "Podrobnosti úlohy", + "taskDetailsInfoTab": "Podrobnosti", + "taskDetailsFilesTab": "Súbory", + "taskDetailsStatsTab": "Štatistiky", + "noStatistics": "Nie sú dostupné žiadne štatistiky", + "noPeers": "Žiadne pripojenia", + "deleteTasksTitle": "{count, plural, =1{Odstrániť úlohu?} other{Odstrániť {count} úloh?}}", + "keepFilesDescription": "Odstrániť iba záznam úlohy z Gopeed", + "readingRuntimeStatus": "Čítanie stavu runtime…", + "apiConfigSaved": "Nastavenia API uložené", + "apiConfigPendingApply": "Uložené nastavenia neboli použité na bežiacu službu", + "apiServerRunning": "Spustené", + "apiServerStopped": "Zastavené", + "apiServerFailed": "Chyba", + "apiServerRuntimeStatus": "Stav runtime", + "startApiServer": "Spustiť", + "stopApiServer": "Zastaviť", + "saveAndRestartApiServer": "Uložiť a reštartovať", + "restartApiServerConfirm": "Služba REST API je spustená. Použitie týchto nastavení ju reštartuje a preruší existujúce API pripojenia.", + "disableApiServerConfirm": "Zakázať server REST API? Existujúce API pripojenia budú prerušené.", + "socketPathRequired": "Cesta soketu je povinná", + "serverRequired": "Server je povinný", + "portRangeError": "Zadajte port medzi 0 a 65535", + "portInUseValue": "Port {port} sa už používa", + "backgroundLocationPermissionRequired": "Pre sťahovanie na pozadí je potrebné povolenie polohy Vždy povoliť", + "scriptPath": "Cesta skriptu", + "logDirectoryUnavailable": "Priečinok protokolov nie je inicializovaný", + "unableOpenExternalLink": "Nepodarilo sa otvoriť externý odkaz", + "neverUpdated": "Nikdy neaktualizované", + "lastUpdated": "Posledná aktualizácia: {time}", + "unableLoadSettings": "Nepodarilo sa načítať nastavenia", + "fieldRequired": "{field} je povinné", + "addGithubMirror": "Pridať GitHub zrkadlo", + "editGithubMirror": "Upraviť GitHub zrkadlo", + "confirmDeleteTitle": "Potvrdiť odstránenie?", + "confirmDeleteNamed": "Naozaj chcete odstrániť „{name}“?", + "categoryFieldsRequired": "Názov kategórie a cesta sú povinné", + "addCategory": "Pridať kategóriu", + "editCategory": "Upraviť kategóriu", + "deleteCategoryTitle": "Odstrániť kategóriu?", + "updateFailed": "Aktualizácia zlyhala. Skúste to znova neskôr", + "newVersionAvailable": "Je dostupná nová verzia", + "releaseNotes": "Čo je nové", + "noReleaseNotes": "Nie sú dostupné žiadne poznámky k vydaniu", + "preparingDownload": "Príprava sťahovania…", + "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 služba na pozadí", + "androidForegroundNotificationTitle": "Gopeed je spustený", + "androidForegroundNotificationText": "Sťahovania môžu pokračovať na pozadí", + "mcpDescription": "Spolupracujte s AI agentmi a objavujte neobmedzené možnosti", + "connectAgent": "Príručka nastavenia", + "connectMcpAgent": "Pripojiť MCP agenta", + "mcpAgentDialogDescription": "Vyberte agenta, ktorého používate, a skopírujte jeho konfiguráciu pripojenia.", + "mcpNotRunningHint": "Služba MCP nie je povolená. Povoľte ju", + "webMcpApiTokenHint": "Ak ste nakonfigurovali API token, pri pripájaní agenta sú potrebné overovacie údaje", + "useMcpApiTokenTemplate": "Prepnúť na overenú konfiguráciu", + "webApiConfigReadOnlyHelp": "Webová verzia nemôže upravovať nastavenia API online. Aktualizujte možnosti spustenia servera alebo konfiguračný súbor a potom reštartujte službu, aby sa zmeny prejavili.", + "mcpRequiresTcp": "Pokyny na pripojenie agenta sú dostupné, keď je protokol API TCP.", + "emptyTaskCreateHint": "Kliknutím na {action} spustíte sťahovanie", + "@emptyTaskCreateHint": { + "description": "Usmernenie pre prázdny zoznam; akcia sa nahradí vloženým tlačidlom vytvorenia.", + "placeholders": { + "action": { + "type": "String" + } + } + } +} diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index ab080db7..6c485524 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.2+2 +version: 2.0.0-beta.3+3 environment: sdk: ^3.11.4