mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-11 10:54:08 +00:00
Compare commits
201
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc96cff2ea | ||
|
|
15738644b0 | ||
|
|
44a5f9eefc | ||
|
|
c90c690836 | ||
|
|
dee1c2515f | ||
|
|
bbf1a9fd7d | ||
|
|
b3f462bb7d | ||
|
|
0d08004c49 | ||
|
|
1589d4cbe7 | ||
|
|
b6ce7756fb | ||
|
|
342c55fbd6 | ||
|
|
fe2f45e40d | ||
|
|
ba84a04a43 | ||
|
|
2d4a8cddc1 | ||
|
|
224b488087 | ||
|
|
7ce4fb7f0e | ||
|
|
76c22c50d8 | ||
|
|
f400137ead | ||
|
|
a5ebbca318 | ||
|
|
8f2af5660e | ||
|
|
932a49c1d1 | ||
|
|
212c735b66 | ||
|
|
8d1ae93b03 | ||
|
|
b5e8d61f01 | ||
|
|
363153fa33 | ||
|
|
f0dab90e09 | ||
|
|
6e60889ce8 | ||
|
|
929e2d2d7c | ||
|
|
e93437c95f | ||
|
|
88d150b933 | ||
|
|
7bc7efed5b | ||
|
|
1d8a502ac4 | ||
|
|
4b231f52c5 | ||
|
|
e956bd1ce5 | ||
|
|
68402c85d3 | ||
|
|
a480c5bd14 | ||
|
|
e779de285d | ||
|
|
fac17b0ccc | ||
|
|
8c728ffda7 | ||
|
|
1b4956b0ec | ||
|
|
2e5f8948be | ||
|
|
04ebca60cf | ||
|
|
948f6d5487 | ||
|
|
51b7abed48 | ||
|
|
d8758267c4 | ||
|
|
d94b59b165 | ||
|
|
f34cb9c971 | ||
|
|
172f4a3650 | ||
|
|
694b43e3f9 | ||
|
|
085ebf357a | ||
|
|
dae13dec01 | ||
|
|
a8f682559f | ||
|
|
3c0c019bc9 | ||
|
|
69b3e223ad | ||
|
|
183616fcc0 | ||
|
|
3415364ab7 | ||
|
|
1b00a16277 | ||
|
|
b33c00d90d | ||
|
|
7e07ea31cc | ||
|
|
c2da22f371 | ||
|
|
2fd8a96c5d | ||
|
|
1776b05782 | ||
|
|
ffbae58e6b | ||
|
|
2c7a86b21c | ||
|
|
4157e0a5bc | ||
|
|
dfe8e9a3fd | ||
|
|
7c33d978c4 | ||
|
|
534fbbb3a2 | ||
|
|
fdb2768008 | ||
|
|
27202cf99e | ||
|
|
a05eea3268 | ||
|
|
7496331ffd | ||
|
|
967ef25408 | ||
|
|
d1b2ae42af | ||
|
|
adeb7a2895 | ||
|
|
edfecefc16 | ||
|
|
f7dfc4226b | ||
|
|
edb8d3cbdc | ||
|
|
c6dd58848d | ||
|
|
88dd35cc46 | ||
|
|
051d4267a5 | ||
|
|
022fbf633b | ||
|
|
014221a1a7 | ||
|
|
6f4635e2eb | ||
|
|
a83e3a2b84 | ||
|
|
5bb040eaaf | ||
|
|
e27c7dddb4 | ||
|
|
5342be16d9 | ||
|
|
e0ced260e2 | ||
|
|
d41e27234d | ||
|
|
c703c52cbc | ||
|
|
b88144c864 | ||
|
|
273850ae3f | ||
|
|
07356033f9 | ||
|
|
166aee81be | ||
|
|
45d7b6f5bc | ||
|
|
5845e9ea5f | ||
|
|
f9410bbcc7 | ||
|
|
f1f17948c6 | ||
|
|
675028c64a | ||
|
|
b6d18a267b | ||
|
|
40cfb167f6 | ||
|
|
4fd1939732 | ||
|
|
3ed54ba93a | ||
|
|
fe64a328ac | ||
|
|
7e49b678f2 | ||
|
|
311e002e55 | ||
|
|
119fad9a94 | ||
|
|
e2601ea9fa | ||
|
|
a5eaa6b528 | ||
|
|
bed624bfce | ||
|
|
eec53c15b2 | ||
|
|
d58135db9f | ||
|
|
d642ae5018 | ||
|
|
e358123b22 | ||
|
|
ee9b16794e | ||
|
|
4f74101b3a | ||
|
|
c47b735fc4 | ||
|
|
ca98806dcc | ||
|
|
a0e84a75b1 | ||
|
|
14b22cf485 | ||
|
|
a3f6ec5885 | ||
|
|
f839c3ac93 | ||
|
|
67d4aa9554 | ||
|
|
c355f9ff5c | ||
|
|
59a7d40bfe | ||
|
|
5d83e91ce5 | ||
|
|
637004c8a8 | ||
|
|
e79b37d08c | ||
|
|
d393d27ec7 | ||
|
|
2e8672273c | ||
|
|
4802802828 | ||
|
|
3d372f98de | ||
|
|
d578fe96bc | ||
|
|
1262844363 | ||
|
|
91bea83692 | ||
|
|
e21bc5eeae | ||
|
|
6331060171 | ||
|
|
f3ec6b8ee9 | ||
|
|
9124976b5c | ||
|
|
9820e58de9 | ||
|
|
e890e593d9 | ||
|
|
6572e9e899 | ||
|
|
1c57b07575 | ||
|
|
6f12eb1f07 | ||
|
|
bd03d3b31c | ||
|
|
d330d8bea8 | ||
|
|
ee6b073bc1 | ||
|
|
a41c6c145c | ||
|
|
86c8383584 | ||
|
|
75462fe2be | ||
|
|
f35111e072 | ||
|
|
6aabe69f15 | ||
|
|
527539f3a1 | ||
|
|
2d239495a0 | ||
|
|
ddce649c8b | ||
|
|
29675858f2 | ||
|
|
1fc0ff6738 | ||
|
|
0851e27c6d | ||
|
|
3abc8d42fa | ||
|
|
71d7d96cc0 | ||
|
|
f254a82d9c | ||
|
|
c4ae3330b8 | ||
|
|
a45b6455be | ||
|
|
979988a7f4 | ||
|
|
9efe7ff4c9 | ||
|
|
77734c69bc | ||
|
|
22db46c68a | ||
|
|
99a62357ab | ||
|
|
9558042513 | ||
|
|
39912718c4 | ||
|
|
3b6b134f24 | ||
|
|
f0aa44455c | ||
|
|
99ed770017 | ||
|
|
f506b8f92f | ||
|
|
9b9fd12884 | ||
|
|
cea912b414 | ||
|
|
7c3d7269fe | ||
|
|
1554791e17 | ||
|
|
b0aecdcfc6 | ||
|
|
d2eb7b2a67 | ||
|
|
7112845c82 | ||
|
|
39820707be | ||
|
|
47db539d91 | ||
|
|
f32f7583e4 | ||
|
|
e3ae2865d6 | ||
|
|
1a8d73fbe6 | ||
|
|
5728caf7db | ||
|
|
77d95971ca | ||
|
|
2c81520029 | ||
|
|
8d45eb4fef | ||
|
|
31fa0e5bc9 | ||
|
|
0f60f40e3e | ||
|
|
b17c0fd7c8 | ||
|
|
a834ca0d74 | ||
|
|
ecfd07ccb0 | ||
|
|
d51eeb3f2c | ||
|
|
1bb3f74c9f | ||
|
|
f7be97667f | ||
|
|
607d1729e6 | ||
|
|
d12122ea86 |
No files matched your search
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@ Gopeed を AI Agent と連携すると、自然言語でダウンロードを管
|
|||||||
|
|
||||||
Gopeed 2.0.0 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから FFI で Go コアへ直接接続するネイティブ通信アーキテクチャ、より統一されたクロスプラットフォーム体験、強化されたタスク管理、より柔軟な API、MCP による 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 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。
|
機能と安定性が正式リリースの基準に達した時点で、Gopeed 2.0.0 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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 đề.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go
|
|||||||
|
|
||||||
Gopeed 2.0.0 目前处于公开 Beta 测试阶段,引入了全新界面、通过 FFI 直连 Go 核心的原生通信架构、更一致的跨平台体验、更完善的任务管理、更灵活的 API,以及用于 AI Agent 接入的 MCP 能力。部分功能仍可能不够完善或稳定,欢迎提前体验并向我们反馈问题。
|
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 渠道。
|
当功能完整度和稳定性达到正式发布标准后,我们会发布 Gopeed 2.0.0 正式版。已安装 Beta 版本的用户可以直接升级到最终正式版,现有稳定版用户则不会被自动切换到 Beta 渠道。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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 測試階段,引入了全新介面、透過 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 頻道。
|
當功能完整度與穩定性達到正式發布標準後,我們會發布 Gopeed 2.0.0 正式版。已安裝 Beta 版本的使用者可以直接升級至最終正式版,現有穩定版使用者則不會被自動切換至 Beta 頻道。
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -7,9 +7,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type args struct {
|
type args struct {
|
||||||
url string
|
url string
|
||||||
connections *int
|
connections *int
|
||||||
dir *string
|
dir *string
|
||||||
|
checksum *string
|
||||||
|
checksumAlgo *string
|
||||||
}
|
}
|
||||||
|
|
||||||
func parse() *args {
|
func parse() *args {
|
||||||
@@ -20,6 +22,8 @@ func parse() *args {
|
|||||||
var args args
|
var args args
|
||||||
args.connections = flag.Int("C", 16, "Concurrent connections.")
|
args.connections = flag.Int("C", 16, "Concurrent connections.")
|
||||||
args.dir = flag.String("D", dir, "Store directory.")
|
args.dir = flag.String("D", dir, "Store directory.")
|
||||||
|
args.checksum = flag.String("checksum", "", "Expected checksum hash (hex encoded).")
|
||||||
|
args.checksumAlgo = flag.String("checksum-algo", "md5", "Checksum algorithm: md5, sha1, or sha256.")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
t := flag.Args()
|
t := flag.Args()
|
||||||
if len(t) > 0 {
|
if len(t) > 0 {
|
||||||
|
|||||||
+17
-2
@@ -19,6 +19,18 @@ func main() {
|
|||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
|
var checksumOpt *http.ChecksumOption
|
||||||
|
if args.checksum != nil && *args.checksum != "" {
|
||||||
|
algo := "md5"
|
||||||
|
if args.checksumAlgo != nil && *args.checksumAlgo != "" {
|
||||||
|
algo = *args.checksumAlgo
|
||||||
|
}
|
||||||
|
checksumOpt = &http.ChecksumOption{
|
||||||
|
Algorithm: algo,
|
||||||
|
Expected: *args.checksum,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, err := download.Boot().
|
_, err := download.Boot().
|
||||||
URL(args.url).
|
URL(args.url).
|
||||||
Listener(func(event *download.Event) {
|
Listener(func(event *download.Event) {
|
||||||
@@ -43,8 +55,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
}).
|
}).
|
||||||
Create(&base.Options{
|
Create(&base.Options{
|
||||||
Path: *args.dir,
|
Path: *args.dir,
|
||||||
Extra: http.OptsExtra{Connections: *args.connections},
|
Extra: http.OptsExtra{
|
||||||
|
Connections: *args.connections,
|
||||||
|
Checksum: checksumOpt,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|||||||
@@ -83,10 +83,15 @@ func TestWaitCleansUnselectedFilesFromSharedPiece(t *testing.T) {
|
|||||||
{Length: 4, Path: []string{"keep.txt"}},
|
{Length: 4, Path: []string{"keep.txt"}},
|
||||||
{Length: 4, Path: []string{"nested", "skip.txt"}},
|
{Length: 4, Path: []string{"nested", "skip.txt"}},
|
||||||
}}
|
}}
|
||||||
store := storage.NewFileOpts(storage.NewFileClientOpts{
|
storageOpts := storage.NewFileClientOpts{
|
||||||
ClientBaseDir: dir,
|
ClientBaseDir: dir,
|
||||||
TorrentDirMaker: func(baseDir string, _ *metainfo.Info, _ metainfo.Hash) string { return baseDir },
|
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()
|
defer store.Close()
|
||||||
config := torrent.TestingConfig(t)
|
config := torrent.TestingConfig(t)
|
||||||
config.DisableTCP = true
|
config.DisableTCP = true
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ package http
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -280,6 +285,12 @@ type Fetcher struct {
|
|||||||
primaryReadyOnce sync.Once
|
primaryReadyOnce sync.Once
|
||||||
primaryReadyCh chan struct{}
|
primaryReadyCh chan struct{}
|
||||||
|
|
||||||
|
// checksumFailed marks that the last completed download failed
|
||||||
|
// checksum verification, so the next retry must force a full
|
||||||
|
// re-download instead of skipping connections that already
|
||||||
|
// reported Completed.
|
||||||
|
checksumFailed atomic.Bool
|
||||||
|
|
||||||
// Start pending mechanism
|
// Start pending mechanism
|
||||||
startPending atomic.Bool
|
startPending atomic.Bool
|
||||||
resolvedCh chan struct{} // Signal when resolve completes
|
resolvedCh chan struct{} // Signal when resolve completes
|
||||||
@@ -417,12 +428,26 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
|||||||
res.Range = true
|
res.Range = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get content length from Content-Length header
|
// A partial response describes one segment, not the whole resource.
|
||||||
contentLength := resp.Header.Get(base.HttpHeaderContentLength)
|
reuseResolveBody := true
|
||||||
if contentLength != "" {
|
if resp.StatusCode == base.HttpCodePartialContent {
|
||||||
parse, err := strconv.ParseInt(contentLength, 10, 64)
|
start, _, total, err := parseRangeResponse(resp)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
res.Size = parse
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +492,12 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
|||||||
// IMPORTANT: Keep the response body open for downloading in Start phase
|
// IMPORTANT: Keep the response body open for downloading in Start phase
|
||||||
// This is crucial for one-time URLs that can only be accessed once
|
// This is crucial for one-time URLs that can only be accessed once
|
||||||
f.resolveRespLock.Lock()
|
f.resolveRespLock.Lock()
|
||||||
f.resolveResp = resp
|
if reuseResolveBody {
|
||||||
|
f.resolveResp = resp
|
||||||
|
} else {
|
||||||
|
resp.Body.Close()
|
||||||
|
f.prefetchDone.Store(true)
|
||||||
|
}
|
||||||
f.resolveRespLock.Unlock()
|
f.resolveRespLock.Unlock()
|
||||||
|
|
||||||
f.setState(stateResolved)
|
f.setState(stateResolved)
|
||||||
@@ -479,7 +509,7 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
|||||||
|
|
||||||
// Start async prefetch in background (only for range-supported resources)
|
// Start async prefetch in background (only for range-supported resources)
|
||||||
// For non-range resources, the response will be used directly in Start
|
// 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.prefetchStopCh = make(chan struct{})
|
||||||
f.resolveFallback.Store(true)
|
f.resolveFallback.Store(true)
|
||||||
go f.asyncPrefetch()
|
go f.asyncPrefetch()
|
||||||
@@ -716,12 +746,26 @@ func (f *Fetcher) doStart() error {
|
|||||||
|
|
||||||
// If retrying after error, reset connection states for retry
|
// If retrying after error, reset connection states for retry
|
||||||
if state == stateError {
|
if state == stateError {
|
||||||
|
forceFullReset := f.checksumFailed.Swap(false)
|
||||||
|
if forceFullReset {
|
||||||
|
// Checksum mismatch: the file content itself is wrong even
|
||||||
|
// though every connection reported Completed. Truncate the
|
||||||
|
// corrupt file so stale bytes cannot linger if the retry is
|
||||||
|
// interrupted before every connection re-writes its chunk.
|
||||||
|
if err := os.Truncate(f.meta.SingleFilepath(), 0); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to reset file after checksum mismatch: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
f.connMu.Lock()
|
f.connMu.Lock()
|
||||||
for _, conn := range f.connections {
|
for _, conn := range f.connections {
|
||||||
// Reset connections that can be retried
|
// On a normal error, only reset connections that didn't
|
||||||
if !conn.Completed && conn.State != connCompleted {
|
// finish. On a checksum mismatch, reset EVERY connection —
|
||||||
|
// "Completed" only means the download loop finished, not
|
||||||
|
// that the bytes were verified correct.
|
||||||
|
shouldReset := forceFullReset || (!conn.Completed && conn.State != connCompleted)
|
||||||
|
if shouldReset {
|
||||||
if !f.hasSequentialPrefixLocked(conn) {
|
if !f.hasSequentialPrefixLocked(conn) {
|
||||||
f.resetConnectionForRestart(conn)
|
f.resetConnectionForRestart(conn, forceFullReset)
|
||||||
}
|
}
|
||||||
conn.State = connNotStarted
|
conn.State = connNotStarted
|
||||||
conn.failed = false
|
conn.failed = false
|
||||||
@@ -951,8 +995,14 @@ func (f *Fetcher) expandConnections() {
|
|||||||
}
|
}
|
||||||
f.fileMu.Unlock()
|
f.fileMu.Unlock()
|
||||||
|
|
||||||
f.setState(stateDone)
|
var err error
|
||||||
f.doneCh <- nil
|
if chkErr := f.verifyChecksum(); chkErr != nil {
|
||||||
|
err = chkErr
|
||||||
|
f.setState(stateError)
|
||||||
|
} else {
|
||||||
|
f.setState(stateDone)
|
||||||
|
}
|
||||||
|
f.doneCh <- err
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1088,7 +1138,7 @@ func (f *Fetcher) runConnection(conn *connection) {
|
|||||||
// it only when no safe validator exists or If-Range returns a full 200.
|
// it only when no safe validator exists or If-Range returns a full 200.
|
||||||
f.connMu.Lock()
|
f.connMu.Lock()
|
||||||
if !f.hasSequentialPrefixLocked(conn) {
|
if !f.hasSequentialPrefixLocked(conn) {
|
||||||
f.resetConnectionForRestart(conn)
|
f.resetConnectionForRestart(conn, false)
|
||||||
}
|
}
|
||||||
f.connMu.Unlock()
|
f.connMu.Unlock()
|
||||||
}
|
}
|
||||||
@@ -1195,7 +1245,7 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
|
|||||||
resumeProbe := f.canProbeSequentialResumeLocked(conn)
|
resumeProbe := f.canProbeSequentialResumeLocked(conn)
|
||||||
intentionalRestart := sequentialSizeUnknown
|
intentionalRestart := sequentialSizeUnknown
|
||||||
if sequentialSizeUnknown || (hasSequentialPrefix && !resumeProbe) {
|
if sequentialSizeUnknown || (hasSequentialPrefix && !resumeProbe) {
|
||||||
f.resetConnectionForRestart(conn)
|
f.resetConnectionForRestart(conn, false)
|
||||||
f.resolveDataPos.Store(0)
|
f.resolveDataPos.Store(0)
|
||||||
f.rangeValidatorPinned = false
|
f.rangeValidatorPinned = false
|
||||||
intentionalRestart = true
|
intentionalRestart = true
|
||||||
@@ -1544,27 +1594,41 @@ func (f *Fetcher) completeFromResolveFallback(conn *connection) bool {
|
|||||||
return true
|
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)
|
contentRange := resp.Header.Get(base.HttpHeaderContentRange)
|
||||||
fields := strings.Fields(contentRange)
|
fields := strings.Fields(contentRange)
|
||||||
if len(fields) != 2 || !strings.EqualFold(fields[0], base.HttpHeaderBytes) {
|
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], "/")
|
rangeAndTotal := strings.Split(fields[1], "/")
|
||||||
if len(rangeAndTotal) != 2 {
|
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], "-")
|
bounds := strings.Split(rangeAndTotal[0], "-")
|
||||||
if len(bounds) != 2 {
|
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)
|
start, startErr := strconv.ParseInt(bounds[0], 10, 64)
|
||||||
end, endErr := strconv.ParseInt(bounds[1], 10, 64)
|
end, endErr := strconv.ParseInt(bounds[1], 10, 64)
|
||||||
total, totalErr := strconv.ParseInt(rangeAndTotal[1], 10, 64)
|
total, totalErr := strconv.ParseInt(rangeAndTotal[1], 10, 64)
|
||||||
if startErr != nil || endErr != nil || totalErr != nil || start < 0 || end < start || total <= end {
|
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) {
|
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)
|
return 0, 0, fmt.Errorf("%w: Content-Range %d-%d does not match requested range", errInvalidRangeResponse, start, end)
|
||||||
@@ -1573,11 +1637,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)
|
return 0, 0, fmt.Errorf("%w: Content-Range total %d does not match resource size %d", errInvalidRangeResponse, total, totalSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedLength := end - start + 1
|
return end - start + 1, total, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) resolveOpenEndedRange(conn *connection, totalSize int64) error {
|
func (f *Fetcher) resolveOpenEndedRange(conn *connection, totalSize int64) error {
|
||||||
@@ -1630,7 +1690,7 @@ func (f *Fetcher) fallbackToSequentialDownload(conn *connection, ifRange string)
|
|||||||
f.rangeReprobeEligible = true
|
f.rangeReprobeEligible = true
|
||||||
f.rangeValidatorPinned = false
|
f.rangeValidatorPinned = false
|
||||||
f.ifRange = ifRange
|
f.ifRange = ifRange
|
||||||
f.resetConnectionForRestart(conn)
|
f.resetConnectionForRestart(conn, false)
|
||||||
f.resolveDataPos.Store(0)
|
f.resolveDataPos.Store(0)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1646,7 +1706,7 @@ func (f *Fetcher) restartSequentialDownload(conn *connection, ifRange string) {
|
|||||||
f.rangeReprobeEligible = true
|
f.rangeReprobeEligible = true
|
||||||
f.rangeValidatorPinned = false
|
f.rangeValidatorPinned = false
|
||||||
f.ifRange = ifRange
|
f.ifRange = ifRange
|
||||||
f.resetConnectionForRestart(conn)
|
f.resetConnectionForRestart(conn, false)
|
||||||
f.resolveDataPos.Store(0)
|
f.resolveDataPos.Store(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2029,16 +2089,25 @@ func (f *Fetcher) helpOtherConnection(helper *connection) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) resetConnectionForRestart(conn *connection) {
|
func (f *Fetcher) resetConnectionForRestart(conn *connection, force bool) {
|
||||||
if f.meta.Res.Range {
|
if f.meta.Res.Range && !force {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if f.meta.Res.Range && force {
|
||||||
|
// Checksum mismatch: the on-disk content for this chunk is
|
||||||
|
// confirmed wrong, so its bytes must be re-fetched from the
|
||||||
|
// origin server even though range support would normally let
|
||||||
|
// us just resume from the existing Downloaded position.
|
||||||
|
if conn.Chunk != nil {
|
||||||
|
conn.Chunk.Downloaded = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Without range support a new request always starts from byte 0,
|
// Without range support a new request always starts from byte 0,
|
||||||
// so pause/retry must restart instead of continuing from the old offset.
|
// so pause/retry must restart instead of continuing from the old offset.
|
||||||
if conn.Chunk == nil {
|
if conn.Chunk == nil {
|
||||||
conn.Chunk = newChunk(0, 0)
|
conn.Chunk = newChunk(0, 0)
|
||||||
} else {
|
} else if !f.meta.Res.Range {
|
||||||
conn.Chunk.Begin = 0
|
conn.Chunk.Begin = 0
|
||||||
conn.Chunk.setEnd(0)
|
conn.Chunk.setEnd(0)
|
||||||
conn.Chunk.Downloaded = 0
|
conn.Chunk.Downloaded = 0
|
||||||
@@ -2102,7 +2171,7 @@ func (f *Fetcher) resumeConnections() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !f.hasSequentialPrefixLocked(conn) {
|
if !f.hasSequentialPrefixLocked(conn) {
|
||||||
f.resetConnectionForRestart(conn)
|
f.resetConnectionForRestart(conn, false)
|
||||||
}
|
}
|
||||||
// Reset the connection state for resume
|
// Reset the connection state for resume
|
||||||
conn.ctx, conn.cancel = context.WithCancel(f.ctx)
|
conn.ctx, conn.cancel = context.WithCancel(f.ctx)
|
||||||
@@ -2209,10 +2278,15 @@ func (f *Fetcher) onDownloadComplete() {
|
|||||||
}
|
}
|
||||||
f.fileMu.Unlock()
|
f.fileMu.Unlock()
|
||||||
|
|
||||||
if finalErr != nil {
|
if finalErr == nil {
|
||||||
f.setState(stateError)
|
if chkErr := f.verifyChecksum(); chkErr != nil {
|
||||||
|
finalErr = chkErr
|
||||||
|
f.setState(stateError)
|
||||||
|
} else {
|
||||||
|
f.setState(stateDone)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
f.setState(stateDone)
|
f.setState(stateError)
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -2221,6 +2295,51 @@ func (f *Fetcher) onDownloadComplete() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) verifyChecksum() error {
|
||||||
|
if f.meta == nil || f.meta.Opts == nil || f.meta.Opts.Extra == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
extra, ok := f.meta.Opts.Extra.(*fhttp.OptsExtra)
|
||||||
|
if !ok || extra.Checksum == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
chk := extra.Checksum
|
||||||
|
if chk.Algorithm == "" && chk.Expected == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var h hash.Hash
|
||||||
|
switch strings.ToLower(strings.TrimSpace(chk.Algorithm)) {
|
||||||
|
case "md5":
|
||||||
|
h = md5.New()
|
||||||
|
case "sha1", "sha-1":
|
||||||
|
h = sha1.New()
|
||||||
|
case "sha256", "sha-256":
|
||||||
|
h = sha256.New()
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported checksum algorithm: %s", chk.Algorithm)
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := f.meta.SingleFilepath()
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open file for checksum verification failed: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, 64*1024)
|
||||||
|
if _, err := io.CopyBuffer(h, file, buf); err != nil {
|
||||||
|
return fmt.Errorf("calculate checksum failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual := hex.EncodeToString(h.Sum(nil))
|
||||||
|
expected := strings.ToLower(strings.TrimSpace(chk.Expected))
|
||||||
|
if !strings.EqualFold(actual, expected) {
|
||||||
|
f.checksumFailed.Store(true)
|
||||||
|
return fmt.Errorf("checksum mismatch: expected %s, got %s", expected, actual)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Fetcher) checkCompletion() bool {
|
func (f *Fetcher) checkCompletion() bool {
|
||||||
// Check if all data has been downloaded
|
// Check if all data has been downloaded
|
||||||
f.connMu.Lock()
|
f.connMu.Lock()
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
gohttp "net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetcher_ChecksumMismatch_Retry(t *testing.T) {
|
||||||
|
corruptContent := []byte("Corrupt Initial Download Data!")
|
||||||
|
correctContent := []byte("Correct Retried Download Data!")
|
||||||
|
|
||||||
|
correctSha256Hasher := sha256.New()
|
||||||
|
correctSha256Hasher.Write(correctContent)
|
||||||
|
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
|
||||||
|
|
||||||
|
var reqCount atomic.Int32
|
||||||
|
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||||
|
count := reqCount.Add(1)
|
||||||
|
var served []byte
|
||||||
|
if count == 1 {
|
||||||
|
// First request: serve corrupted content
|
||||||
|
served = corruptContent
|
||||||
|
} else {
|
||||||
|
// Second / subsequent request: serve correct content
|
||||||
|
served = correctContent
|
||||||
|
}
|
||||||
|
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(served)))
|
||||||
|
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||||
|
w.WriteHeader(gohttp.StatusOK)
|
||||||
|
_, _ = w.Write(served)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
downloadDir := t.TempDir()
|
||||||
|
fileName := "test_checksum_retry.txt"
|
||||||
|
|
||||||
|
f := buildFetcher()
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
opts := &base.Options{
|
||||||
|
Path: downloadDir,
|
||||||
|
Name: fileName,
|
||||||
|
Extra: &http.OptsExtra{
|
||||||
|
Connections: 1,
|
||||||
|
Checksum: &http.ChecksumOption{
|
||||||
|
Algorithm: "sha256",
|
||||||
|
Expected: correctSha256,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Initial attempt -> will download corruptContent, failing checksum verification
|
||||||
|
if err := f.Resolve(&base.Request{URL: server.URL + "/retry_test.txt"}, opts); err != nil {
|
||||||
|
t.Fatalf("Resolve error: %v", err)
|
||||||
|
}
|
||||||
|
// Ensure the reusable resolve response has been fully prefetched so the
|
||||||
|
// first attempt deterministically verifies the corrupt initial payload.
|
||||||
|
deadline := time.NewTimer(5 * time.Second)
|
||||||
|
defer deadline.Stop()
|
||||||
|
ticker := time.NewTicker(10 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for !f.prefetchDone.Load() {
|
||||||
|
select {
|
||||||
|
case <-deadline.C:
|
||||||
|
t.Fatal("timed out waiting for checksum test prefetch")
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatalf("Start error: %v", err)
|
||||||
|
}
|
||||||
|
err := f.Wait()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected checksum mismatch error on initial attempt, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "checksum mismatch") {
|
||||||
|
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
|
||||||
|
}
|
||||||
|
if f.getState() != stateError {
|
||||||
|
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Retry download -> should trigger forced full reset, truncate file, and re-download correctContent
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatalf("Start error on retry: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Wait(); err != nil {
|
||||||
|
t.Fatalf("Wait error on retry (expected success): %v", err)
|
||||||
|
}
|
||||||
|
if f.getState() != stateDone {
|
||||||
|
t.Fatalf("expected stateDone after successful retry, got %v", f.getState())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verify on-disk file content matches the re-downloaded bytes
|
||||||
|
filePath := f.meta.SingleFilepath()
|
||||||
|
fileBytes, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read completed file: %v", err)
|
||||||
|
}
|
||||||
|
if string(fileBytes) != string(correctContent) {
|
||||||
|
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Compute hash independently and verify
|
||||||
|
actualHasher := sha256.New()
|
||||||
|
actualHasher.Write(fileBytes)
|
||||||
|
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
|
||||||
|
if actualHash != correctSha256 {
|
||||||
|
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
gohttp "net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||||
|
"github.com/GopeedLab/gopeed/internal/test"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetcher_ChecksumVerification(t *testing.T) {
|
||||||
|
content := []byte("Hello, Gopeed Checksum Verification Test Data!")
|
||||||
|
md5Hasher := md5.New()
|
||||||
|
md5Hasher.Write(content)
|
||||||
|
correctMd5 := hex.EncodeToString(md5Hasher.Sum(nil))
|
||||||
|
|
||||||
|
sha256Hasher := sha256.New()
|
||||||
|
sha256Hasher.Write(content)
|
||||||
|
correctSha256 := hex.EncodeToString(sha256Hasher.Sum(nil))
|
||||||
|
|
||||||
|
wrongHash := "0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||||
|
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(content)))
|
||||||
|
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||||
|
w.WriteHeader(gohttp.StatusOK)
|
||||||
|
_, _ = w.Write(content)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
t.Run("Valid MD5", func(t *testing.T) {
|
||||||
|
f := buildFetcher()
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
opts := &base.Options{
|
||||||
|
Path: t.TempDir(),
|
||||||
|
Name: "test_valid_md5.txt",
|
||||||
|
Extra: &http.OptsExtra{
|
||||||
|
Connections: 1,
|
||||||
|
Checksum: &http.ChecksumOption{
|
||||||
|
Algorithm: "md5",
|
||||||
|
Expected: correctMd5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||||
|
t.Fatalf("Resolve error: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatalf("Start error: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Wait(); err != nil {
|
||||||
|
t.Fatalf("Wait error (expected success): %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Valid SHA256", func(t *testing.T) {
|
||||||
|
f := buildFetcher()
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
opts := &base.Options{
|
||||||
|
Path: t.TempDir(),
|
||||||
|
Name: "test_valid_sha256.txt",
|
||||||
|
Extra: &http.OptsExtra{
|
||||||
|
Connections: 1,
|
||||||
|
Checksum: &http.ChecksumOption{
|
||||||
|
Algorithm: "sha256",
|
||||||
|
Expected: correctSha256,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||||
|
t.Fatalf("Resolve error: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatalf("Start error: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Wait(); err != nil {
|
||||||
|
t.Fatalf("Wait error (expected success): %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Wrong MD5 mismatch", func(t *testing.T) {
|
||||||
|
f := buildFetcher()
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
opts := &base.Options{
|
||||||
|
Path: t.TempDir(),
|
||||||
|
Name: "test_wrong_md5.txt",
|
||||||
|
Extra: &http.OptsExtra{
|
||||||
|
Connections: 1,
|
||||||
|
Checksum: &http.ChecksumOption{
|
||||||
|
Algorithm: "md5",
|
||||||
|
Expected: wrongHash,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||||
|
t.Fatalf("Resolve error: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatalf("Start error: %v", err)
|
||||||
|
}
|
||||||
|
err := f.Wait()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Wait expected checksum mismatch error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "checksum mismatch") {
|
||||||
|
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Unsupported Algorithm", func(t *testing.T) {
|
||||||
|
f := buildFetcher()
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
opts := &base.Options{
|
||||||
|
Path: t.TempDir(),
|
||||||
|
Name: "test_unsupported.txt",
|
||||||
|
Extra: &http.OptsExtra{
|
||||||
|
Connections: 1,
|
||||||
|
Checksum: &http.ChecksumOption{
|
||||||
|
Algorithm: "invalid-algo",
|
||||||
|
Expected: wrongHash,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||||
|
t.Fatalf("Resolve error: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatalf("Start error: %v", err)
|
||||||
|
}
|
||||||
|
err := f.Wait()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Wait expected error for unsupported algorithm, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unsupported checksum algorithm") {
|
||||||
|
t.Fatalf("expected 'unsupported checksum algorithm' in error, got %v", err)
|
||||||
|
}
|
||||||
|
if f.checksumFailed.Load() {
|
||||||
|
t.Fatal("checksumFailed flag should NOT be set on unsupported algorithm error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Case-Insensitive Hash Comparison", func(t *testing.T) {
|
||||||
|
f := buildFetcher()
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
opts := &base.Options{
|
||||||
|
Path: t.TempDir(),
|
||||||
|
Name: "test_case_insensitive.txt",
|
||||||
|
Extra: &http.OptsExtra{
|
||||||
|
Connections: 1,
|
||||||
|
Checksum: &http.ChecksumOption{
|
||||||
|
Algorithm: "sha256",
|
||||||
|
Expected: strings.ToUpper(correctSha256),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||||
|
t.Fatalf("Resolve error: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatalf("Start error: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Wait(); err != nil {
|
||||||
|
t.Fatalf("Wait error (expected success with upper case hash): %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Empty Expected Checksum (Skipped)", func(t *testing.T) {
|
||||||
|
f := buildFetcher()
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
opts := &base.Options{
|
||||||
|
Path: t.TempDir(),
|
||||||
|
Name: "test_empty_expected.txt",
|
||||||
|
Extra: &http.OptsExtra{
|
||||||
|
Connections: 1,
|
||||||
|
Checksum: &http.ChecksumOption{
|
||||||
|
Algorithm: "",
|
||||||
|
Expected: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||||
|
t.Fatalf("Resolve error: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatalf("Start error: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Wait(); err != nil {
|
||||||
|
t.Fatalf("Wait error (expected skip and success): %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Large File Multi-Chunk Streaming (>64KB)", func(t *testing.T) {
|
||||||
|
largeData := make([]byte, 128*1024)
|
||||||
|
for i := range largeData {
|
||||||
|
largeData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
largeSha256Hasher := sha256.New()
|
||||||
|
largeSha256Hasher.Write(largeData)
|
||||||
|
largeSha256 := hex.EncodeToString(largeSha256Hasher.Sum(nil))
|
||||||
|
|
||||||
|
largeServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||||
|
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(largeData)))
|
||||||
|
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||||
|
w.WriteHeader(gohttp.StatusOK)
|
||||||
|
_, _ = w.Write(largeData)
|
||||||
|
}))
|
||||||
|
defer largeServer.Close()
|
||||||
|
|
||||||
|
f := buildFetcher()
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
opts := &base.Options{
|
||||||
|
Path: t.TempDir(),
|
||||||
|
Name: "test_large_file.bin",
|
||||||
|
Extra: &http.OptsExtra{
|
||||||
|
Connections: 1,
|
||||||
|
Checksum: &http.ChecksumOption{
|
||||||
|
Algorithm: "sha256",
|
||||||
|
Expected: largeSha256,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Resolve(&base.Request{URL: largeServer.URL + "/large.bin"}, opts); err != nil {
|
||||||
|
t.Fatalf("Resolve error: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatalf("Start error: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Wait(); err != nil {
|
||||||
|
t.Fatalf("Wait error (expected large file checksum success): %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("No Checksum (Opt-in backward compatibility)", func(t *testing.T) {
|
||||||
|
f := buildFetcher()
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
opts := &base.Options{
|
||||||
|
Path: t.TempDir(),
|
||||||
|
Name: test.DownloadName,
|
||||||
|
Extra: &http.OptsExtra{
|
||||||
|
Connections: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||||
|
t.Fatalf("Resolve error: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatalf("Start error: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Wait(); err != nil {
|
||||||
|
t.Fatalf("Wait error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetConnectionForRestart_ForceOnRangeDownload(t *testing.T) {
|
||||||
|
f := buildFetcher()
|
||||||
|
f.meta = &fetcher.FetcherMeta{
|
||||||
|
Res: &base.Resource{
|
||||||
|
Range: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
conn := &connection{
|
||||||
|
Chunk: newChunk(0, 1024),
|
||||||
|
Downloaded: 512,
|
||||||
|
Completed: true,
|
||||||
|
}
|
||||||
|
conn.Chunk.Downloaded = 512
|
||||||
|
|
||||||
|
// Non-forced reset on range-supported download does not reset Chunk.Downloaded
|
||||||
|
f.resetConnectionForRestart(conn, false)
|
||||||
|
if conn.Chunk.Downloaded != 512 {
|
||||||
|
t.Fatalf("expected Chunk.Downloaded to remain 512 when force is false, got %d", conn.Chunk.Downloaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forced reset on range-supported download resets Chunk.Downloaded to 0
|
||||||
|
f.resetConnectionForRestart(conn, true)
|
||||||
|
if conn.Chunk.Downloaded != 0 {
|
||||||
|
t.Fatalf("expected Chunk.Downloaded to be reset to 0 when force is true, got %d", conn.Chunk.Downloaded)
|
||||||
|
}
|
||||||
|
if conn.Completed {
|
||||||
|
t.Fatal("expected Completed to be false after reset")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -853,22 +854,34 @@ func TestFetcher_InitialIgnoredRangeUsesResponseSize(t *testing.T) {
|
|||||||
resolvePayload := bytes.Repeat([]byte("resolve"), 64*1024)
|
resolvePayload := bytes.Repeat([]byte("resolve"), 64*1024)
|
||||||
replacement := bytes.Repeat([]byte("replacement"), 24*1024)
|
replacement := bytes.Repeat([]byte("replacement"), 24*1024)
|
||||||
var rangeRequests atomic.Int32
|
var rangeRequests atomic.Int32
|
||||||
|
rangeStarted := make(chan struct{})
|
||||||
|
releaseResolve := sync.OnceFunc(func() { close(rangeStarted) })
|
||||||
|
|
||||||
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||||
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||||
if r.Header.Get(base.HttpHeaderRange) == "" {
|
if r.Header.Get(base.HttpHeaderRange) == "" {
|
||||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(resolvePayload)))
|
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(resolvePayload)))
|
||||||
w.WriteHeader(gohttp.StatusOK)
|
w.WriteHeader(gohttp.StatusOK)
|
||||||
|
w.(gohttp.Flusher).Flush()
|
||||||
|
// Keep Resolve prefetch incomplete until the Range request starts.
|
||||||
|
// Otherwise Start may reuse the completed body and skip this scenario.
|
||||||
|
select {
|
||||||
|
case <-rangeStarted:
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
_, _ = w.Write(resolvePayload)
|
_, _ = w.Write(resolvePayload)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rangeRequests.Add(1)
|
rangeRequests.Add(1)
|
||||||
|
releaseResolve()
|
||||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(replacement)))
|
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(replacement)))
|
||||||
w.WriteHeader(gohttp.StatusOK)
|
w.WriteHeader(gohttp.StatusOK)
|
||||||
_, _ = w.Write(replacement)
|
_, _ = w.Write(replacement)
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
defer releaseResolve()
|
||||||
|
|
||||||
f := buildFetcher()
|
f := buildFetcher()
|
||||||
if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}, &base.Options{
|
if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}, &base.Options{
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,24 +25,6 @@ type Request struct {
|
|||||||
SkipVerifyCert bool `json:"skipVerifyCert"`
|
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 {
|
func (r *Request) Validate() error {
|
||||||
if r.URL == "" {
|
if r.URL == "" {
|
||||||
return fmt.Errorf("invalid request url")
|
return fmt.Errorf("invalid request url")
|
||||||
|
|||||||
@@ -1912,6 +1912,7 @@ func (d *Downloader) doPause(task *Task) (err error) {
|
|||||||
}
|
}
|
||||||
if handled {
|
if handled {
|
||||||
d.emit(EventKeyPause, task)
|
d.emit(EventKeyPause, task)
|
||||||
|
d.triggerWebhooks(WebhookEventDownloadPause, task, nil)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,32 @@ type generationTestManager struct {
|
|||||||
|
|
||||||
func TestExtensionTaskMethodsMatchEventCapabilities(t *testing.T) {
|
func TestExtensionTaskMethodsMatchEventCapabilities(t *testing.T) {
|
||||||
taskType := reflect.TypeOf((*Task)(nil))
|
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 {
|
if _, ok := taskType.MethodByName("Continue"); ok {
|
||||||
t.Fatal("regular task unexpectedly exposes Continue")
|
t.Fatal("regular task unexpectedly exposes Continue")
|
||||||
}
|
}
|
||||||
@@ -53,17 +79,11 @@ func TestExtensionTaskMethodsMatchEventCapabilities(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extensionTaskType := reflect.TypeOf((*ExtensionTask)(nil))
|
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 {
|
if _, ok := extensionTaskType.MethodByName("Continue"); ok {
|
||||||
t.Fatal("non-error extension task unexpectedly exposes Continue")
|
t.Fatal("non-error extension task unexpectedly exposes Continue")
|
||||||
}
|
}
|
||||||
|
|
||||||
errorTaskType := reflect.TypeOf((*OnErrorExtensionTask)(nil))
|
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 {
|
if _, ok := errorTaskType.MethodByName("Continue"); !ok {
|
||||||
t.Fatal("onError task does not expose Continue")
|
t.Fatal("onError task does not expose Continue")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package engine_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exercise server-side header permissions, including formerly forbidden names,
|
||||||
|
// download authentication, metadata and non-safelisted no-cors values.
|
||||||
|
var extensionRequestHeaders = [][2]string{
|
||||||
|
{"Accept-Charset", "utf-8"}, {"Accept-Encoding", "identity"},
|
||||||
|
{"Access-Control-Request-Headers", "authorization, x-download"},
|
||||||
|
{"Access-Control-Request-Method", "POST"}, {"Connection", "close"},
|
||||||
|
{"Connection", "keep-alive"},
|
||||||
|
{"Content-Length", "4"}, {"Cookie", "sid=manual"}, {"Cookie2", "legacy=1"},
|
||||||
|
{"Date", "Fri, 09 Oct 2026 00:00:00 GMT"}, {"DNT", "1"},
|
||||||
|
{"Host", "download.example:1234"}, {"Origin", "https://download.example"},
|
||||||
|
{"Permissions-Policy", "camera=()"}, {"Referer", "https://download.example/file"},
|
||||||
|
{"TE", "trailers"}, {"Trailer", "X-Checksum"}, {"Set-Cookie", "sid=request"},
|
||||||
|
{"Set-Cookie2", "legacy=request"}, {"Via", "1.1 proxy"},
|
||||||
|
{"Proxy-Authorization", "Basic dGVzdA=="}, {"Proxy-", "custom"},
|
||||||
|
{"Sec-Fetch-Site", "same-origin"}, {"Sec-Fetch-Mode", "custom"},
|
||||||
|
{"Sec-CH-UA", `"Download client"`}, {"Sec-", "custom"},
|
||||||
|
{"X-HTTP-Method", "TRACE"}, {"X-HTTP-Method-Override", "CONNECT"},
|
||||||
|
{"X-Method-Override", "TRACK"}, {"Authorization", "Bearer token"},
|
||||||
|
{"User-Agent", "Gopeed extension"}, {"Content-Type", "application/json"},
|
||||||
|
{"Range", "bytes=0-1,3-4"}, {"X-Download-Token", "custom"},
|
||||||
|
{"Accept-Language", strings.Repeat("x", 129)},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRequestsSendNodeHeaders(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||||
|
body, err := io.ReadAll(request.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := request.URL.Query().Get("header")
|
||||||
|
value := request.Header.Get(name)
|
||||||
|
if name == "Host" {
|
||||||
|
value = request.Host
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode([]string{value, string(body)})
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
clients := []struct{ name, script string }{
|
||||||
|
{"fetch record", `const response = await fetch(url, {method: "POST", body: "test", headers: {[name]: value}}); return (await response.text()).trim();`},
|
||||||
|
{"fetch no-cors Headers", `const response = await fetch(url, {method: "POST", body: "test", mode: "no-cors", headers: new Headers([[name.toLowerCase(), value]])}); return (await response.text()).trim();`},
|
||||||
|
{"fetch Request clone", `const request = new Request(url, {method: "POST", body: "test", mode: "same-origin", headers: [[name.toUpperCase(), value]]}); const response = await fetch(request.clone()); return (await response.text()).trim();`},
|
||||||
|
}
|
||||||
|
for _, credentials := range []bool{false, true} {
|
||||||
|
clients = append(clients, struct{ name, script string }{
|
||||||
|
fmt.Sprintf("XHR credentials=%t", credentials),
|
||||||
|
fmt.Sprintf(`return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", url);
|
||||||
|
xhr.withCredentials = %t;
|
||||||
|
xhr.setRequestHeader(name.toLowerCase(), value);
|
||||||
|
xhr.onload = () => resolve(xhr.responseText.trim());
|
||||||
|
xhr.onerror = () => reject(new Error("XHR failed"));
|
||||||
|
xhr.send("test");
|
||||||
|
});`, credentials),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, header := range extensionRequestHeaders {
|
||||||
|
for _, client := range clients {
|
||||||
|
t.Run(header[0]+"/"+client.name, func(t *testing.T) {
|
||||||
|
runtime := engine.NewEngine(nil)
|
||||||
|
t.Cleanup(runtime.Close)
|
||||||
|
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
|
||||||
|
const name = %q, value = %q;
|
||||||
|
const url = %q + "?header=" + encodeURIComponent(name);
|
||||||
|
%s
|
||||||
|
})()`, header[0], header[1], server.URL, client.script))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want, _ := json.Marshal([]string{header[1], "test"})
|
||||||
|
if value != string(want) {
|
||||||
|
t.Fatalf("server received %#v, want %s", value, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionHeaderMutationsMatchNode(t *testing.T) {
|
||||||
|
headers := append([][2]string(nil), extensionRequestHeaders...)
|
||||||
|
// Node stores these in Headers/Request, but rejects them during fetch.
|
||||||
|
headers = append(headers, [2]string{"Expect", "100-continue"}, [2]string{"Keep-Alive", "timeout=5"}, [2]string{"Transfer-Encoding", "chunked"}, [2]string{"Upgrade", "websocket"})
|
||||||
|
data, _ := json.Marshal(headers)
|
||||||
|
runtime := engine.NewEngine(nil)
|
||||||
|
t.Cleanup(runtime.Close)
|
||||||
|
_, err := runtime.RunString(fmt.Sprintf(`(() => {
|
||||||
|
for (const mode of ["cors", "same-origin", "no-cors"]) {
|
||||||
|
for (const [name, value] of %s) {
|
||||||
|
const request = new Request("http://example.com", {mode, headers: {[name]: value}});
|
||||||
|
const headers = request.headers;
|
||||||
|
if (headers.get(name) !== value) throw new Error("Filtered init " + name + " in " + mode);
|
||||||
|
headers.delete(name.toUpperCase());
|
||||||
|
if (headers.has(name)) throw new Error("Cannot delete " + name);
|
||||||
|
headers.append(name, value);
|
||||||
|
if (headers.get(name) !== value) throw new Error("Cannot append " + name);
|
||||||
|
headers.set(name, value);
|
||||||
|
if (request.clone().headers.get(name) !== value) throw new Error("Cannot clone " + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})()`, data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRequestsIgnoreUnsupportedTransportHeaders(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||||
|
body, err := io.ReadAll(request.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value := request.Header.Get(request.URL.Query().Get("header"))
|
||||||
|
_ = json.NewEncoder(w).Encode([]string{value, string(body)})
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
for _, header := range [][2]string{
|
||||||
|
{"Expect", "100-continue"}, {"Keep-Alive", "timeout=5"},
|
||||||
|
{"Transfer-Encoding", "chunked"}, {"Upgrade", "websocket"},
|
||||||
|
{"Connection", "upgrade"}, {"Content-Length", "3"},
|
||||||
|
{"Content-Length", "5"}, {"Content-Length", "invalid"},
|
||||||
|
{"Content-Length", "-1"}, {"Content-Length", ""},
|
||||||
|
} {
|
||||||
|
for _, xhr := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("%s=%s/XHR=%t", header[0], header[1], xhr), func(t *testing.T) {
|
||||||
|
runtime := engine.NewEngine(nil)
|
||||||
|
t.Cleanup(runtime.Close)
|
||||||
|
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
|
||||||
|
const name = %q, value = %q;
|
||||||
|
const url = %q + "?header=" + encodeURIComponent(name);
|
||||||
|
if (%t) return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", url);
|
||||||
|
xhr.setRequestHeader(name, value);
|
||||||
|
xhr.onload = () => resolve(xhr.responseText.trim());
|
||||||
|
xhr.onerror = () => reject(new Error("XHR failed"));
|
||||||
|
xhr.send("test");
|
||||||
|
});
|
||||||
|
const response = await fetch(url, {method: "POST", body: "test", headers: {[name]: value}});
|
||||||
|
return (await response.text()).trim();
|
||||||
|
})()`, header[0], header[1], server.URL, xhr))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wireValue := ""
|
||||||
|
if header[0] == "Content-Length" {
|
||||||
|
wireValue = "4"
|
||||||
|
}
|
||||||
|
want, _ := json.Marshal([]string{wireValue, "test"})
|
||||||
|
if value != string(want) {
|
||||||
|
t.Fatalf("server received %#v, want %s", value, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionResponseExposesSetCookie(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Add("Set-Cookie", "sid=one; HttpOnly")
|
||||||
|
w.Header().Add("Set-Cookie", "token=two")
|
||||||
|
w.Header().Set("Set-Cookie2", "legacy=three")
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
runtime := engine.NewEngine(nil)
|
||||||
|
t.Cleanup(runtime.Close)
|
||||||
|
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
|
||||||
|
const response = await fetch(%q);
|
||||||
|
const clone = response.clone();
|
||||||
|
await response.text(); await clone.text();
|
||||||
|
let immutable = false;
|
||||||
|
try { response.headers.set("Set-Cookie", "changed=1"); } catch (error) { immutable = error instanceof TypeError; }
|
||||||
|
const synthetic = new Response(null, {headers: {"Set-Cookie": "initial=1"}});
|
||||||
|
synthetic.headers.append("Set-Cookie", "appended=2");
|
||||||
|
return JSON.stringify({cookies: clone.headers.getSetCookie(), legacy: response.headers.get("set-cookie2"), immutable, synthetic: synthetic.headers.getSetCookie()});
|
||||||
|
})()`, server.URL))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const want = `{"cookies":["sid=one; HttpOnly","token=two"],"legacy":"legacy=three","immutable":true,"synthetic":["initial=1","appended=2"]}`
|
||||||
|
if value != want {
|
||||||
|
t.Fatalf("unexpected response headers: got %#v want %s", value, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionHeadersRejectInvalidSyntax(t *testing.T) {
|
||||||
|
runtime := engine.NewEngine(nil)
|
||||||
|
t.Cleanup(runtime.Close)
|
||||||
|
_, err := runtime.RunString(`(() => {
|
||||||
|
for (const [name, value] of [["Bad Header", "ok"], ["", "ok"], ["X:Header", "ok"],
|
||||||
|
["X-Test", "a\nb"], ["X-Test", "a\rb"], ["X-Test", "\0"], ["X-Test", "\u0100"]]) {
|
||||||
|
for (const create of [() => new Headers([[name, value]]),
|
||||||
|
() => new Request("http://example.com", {mode: "no-cors", headers: {[name]: value}}),
|
||||||
|
() => { const xhr = new XMLHttpRequest(); xhr.open("GET", "http://example.com"); xhr.setRequestHeader(name, value); }]) {
|
||||||
|
let rejected = false;
|
||||||
|
try { create(); } catch (error) { rejected = error instanceof TypeError; }
|
||||||
|
if (!rejected) throw new Error("Accepted invalid HTTP header syntax");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})()`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,6 +105,105 @@ func TestXMLHttpRequestUsesFetch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtensionRequestsSendExplicitCookie(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(request.Header.Get("Cookie")))
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
type requestTest struct {
|
||||||
|
name string
|
||||||
|
script string
|
||||||
|
}
|
||||||
|
tests := []requestTest{
|
||||||
|
{"fetch record", `const response = await fetch(url, {headers: {Cookie: cookie}}); return response.text();`},
|
||||||
|
{"fetch Headers", `const response = await fetch(url, {headers: new Headers({cookie})}); return response.text();`},
|
||||||
|
{"fetch pairs", `const response = await fetch(url, {headers: [["cOoKiE", cookie]]}); return response.text();`},
|
||||||
|
{"fetch repeated Cookie", `const headers = new Headers([["Cookie", "sid=manual"], ["cookie", "token=provided"]]); if (headers.get("cookie") !== cookie) throw new Error("Incorrect Cookie separator"); const response = await fetch(url, {headers}); return response.text();`},
|
||||||
|
{"fetch Request clone", `const request = new Request(url, {headers: {Cookie: cookie}}); const response = await fetch(request.clone()); return response.text();`},
|
||||||
|
{"fetch Request set", `const request = new Request(url); request.headers.set("Cookie", cookie); const response = await fetch(request); return response.text();`},
|
||||||
|
{"fetch Request append", `const request = new Request(url); request.headers.append("Cookie", cookie); const response = await fetch(request); return response.text();`},
|
||||||
|
{"fetch no-cors mutations", `const request = new Request(url, {mode: "no-cors"}); request.headers.set("Cookie", "removed=1"); request.headers.delete("COOKIE"); if (request.headers.has("cookie")) throw new Error("Cookie was not deleted"); request.headers.append("Cookie", cookie); const response = await fetch(request.clone()); return response.text();`},
|
||||||
|
}
|
||||||
|
for _, mode := range []string{"cors", "same-origin", "no-cors"} {
|
||||||
|
for _, credentials := range []string{"omit", "same-origin", "include"} {
|
||||||
|
tests = append(tests, requestTest{
|
||||||
|
name: "fetch " + mode + " " + credentials,
|
||||||
|
script: fmt.Sprintf(`const response = await fetch(url, {headers: {Cookie: cookie}, mode: %q, credentials: %q}); return response.text();`, mode, credentials),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, withCredentials := range []bool{false, true} {
|
||||||
|
tests = append(tests, requestTest{
|
||||||
|
name: fmt.Sprintf("XHR withCredentials=%t", withCredentials),
|
||||||
|
script: fmt.Sprintf(`return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("GET", url);
|
||||||
|
xhr.withCredentials = %t;
|
||||||
|
xhr.setRequestHeader("cOoKiE", cookie);
|
||||||
|
xhr.onload = () => resolve(xhr.responseText);
|
||||||
|
xhr.onerror = () => reject(new Error("XHR failed"));
|
||||||
|
xhr.send();
|
||||||
|
});`, withCredentials),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
tests = append(tests, requestTest{"XHR repeated Cookie", `return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("GET", url);
|
||||||
|
xhr.setRequestHeader("Cookie", "sid=manual");
|
||||||
|
xhr.setRequestHeader("cookie", "token=provided");
|
||||||
|
xhr.onload = () => resolve(xhr.responseText);
|
||||||
|
xhr.onerror = () => reject(new Error("XHR failed"));
|
||||||
|
xhr.send();
|
||||||
|
});`})
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
runtime := engine.NewEngine(nil)
|
||||||
|
t.Cleanup(runtime.Close)
|
||||||
|
const cookie = "sid=manual; token=provided"
|
||||||
|
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
|
||||||
|
const url = %q;
|
||||||
|
const cookie = %q;
|
||||||
|
%s
|
||||||
|
})()`, server.URL, cookie, test.script))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value != cookie {
|
||||||
|
t.Fatalf("server received Cookie %q, want %q", value, cookie)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchExplicitCookieWithCookieJar(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.URL.Path == "/seed" {
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: "stored", Value: "jar", Path: "/"})
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(request.Header.Get("Cookie")))
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
runtime := engine.NewEngine(nil)
|
||||||
|
t.Cleanup(runtime.Close)
|
||||||
|
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
|
||||||
|
const url = %q;
|
||||||
|
await (await fetch(url + "/seed")).text();
|
||||||
|
const automatic = await (await fetch(url)).text();
|
||||||
|
const combined = await (await fetch(url, {headers: {Cookie: "manual=provided"}})).text();
|
||||||
|
const omitted = await (await fetch(url, {credentials: "omit", headers: {Cookie: "manual=provided"}})).text();
|
||||||
|
const empty = await (await fetch(url, {credentials: "omit"})).text();
|
||||||
|
return JSON.stringify({automatic, combined, omitted, empty});
|
||||||
|
})()`, server.URL))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const want = `{"automatic":"stored=jar","combined":"manual=provided; stored=jar","omitted":"manual=provided","empty":""}`
|
||||||
|
if value != want {
|
||||||
|
t.Fatalf("unexpected CookieJar result: got %#v want %s", value, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFetchResponseAndRequestCloneHaveIndependentBodies(t *testing.T) {
|
func TestFetchResponseAndRequestCloneHaveIndependentBodies(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
if request.Method == http.MethodPost {
|
if request.Method == http.MethodPost {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@
|
|||||||
if (name === "set-cookie") {
|
if (name === "set-cookie") {
|
||||||
for (const value of values) result.push([name, value]);
|
for (const value of values) result.push([name, value]);
|
||||||
} else {
|
} else {
|
||||||
result.push([name, values.join(", ")]);
|
result.push([name, values.join(name === "cookie" ? "; " : ", ")]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -158,49 +158,10 @@
|
|||||||
return iterator;
|
return iterator;
|
||||||
}
|
}
|
||||||
|
|
||||||
function forbiddenRequestHeader(name, value) {
|
function assertMutableHeaders(headers) {
|
||||||
if (name.startsWith("proxy-") || name.startsWith("sec-")) return true;
|
// Match Node/Undici: HTTP syntax and immutability still apply, but browser
|
||||||
if ([
|
// forbidden-header and no-cors safelist rules do not apply to extensions.
|
||||||
"accept-charset", "accept-encoding", "access-control-request-headers",
|
|
||||||
"access-control-request-method", "connection", "content-length", "cookie",
|
|
||||||
"cookie2", "date", "dnt", "expect", "host", "keep-alive", "origin",
|
|
||||||
"permissions-policy", "referer", "te", "trailer", "transfer-encoding",
|
|
||||||
"set-cookie", "upgrade", "via",
|
|
||||||
].includes(name)) return true;
|
|
||||||
if (["x-http-method", "x-http-method-override", "x-method-override"].includes(name)) {
|
|
||||||
return String(value).split(",").some((method) => {
|
|
||||||
method = method.trim().toUpperCase();
|
|
||||||
return method === "CONNECT" || method === "TRACE" || method === "TRACK";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function noCORSSafelistedRequestHeader(name, value) {
|
|
||||||
if (value.length > 128) return false;
|
|
||||||
const hasUnsafeByte = /[\x00-\x08\x0A-\x1F\x7F"():<>?@\[\\\]{}]/.test(value);
|
|
||||||
if (name === "accept") return !hasUnsafeByte;
|
|
||||||
if (name === "accept-language" || name === "content-language") {
|
|
||||||
return /^[0-9A-Za-z *,\-.;=]*$/.test(value);
|
|
||||||
}
|
|
||||||
if (name === "content-type") {
|
|
||||||
if (hasUnsafeByte) return false;
|
|
||||||
const essence = value.split(";", 1)[0].trim().toLowerCase();
|
|
||||||
return essence === "application/x-www-form-urlencoded" ||
|
|
||||||
essence === "multipart/form-data" || essence === "text/plain";
|
|
||||||
}
|
|
||||||
if (name === "range") return /^bytes=[0-9]+-[0-9]*$/.test(value);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function blockedByGuard(headers, name, value) {
|
|
||||||
if (headers._guard === "immutable") throw new TypeError("Headers are immutable");
|
if (headers._guard === "immutable") throw new TypeError("Headers are immutable");
|
||||||
if (headers._guard === "request") return forbiddenRequestHeader(name, value);
|
|
||||||
if (headers._guard === "request-no-cors") {
|
|
||||||
return forbiddenRequestHeader(name, value) || !noCORSSafelistedRequestHeader(name, value);
|
|
||||||
}
|
|
||||||
if (headers._guard === "response") return name === "set-cookie" || name === "set-cookie2";
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Headers {
|
class Headers {
|
||||||
@@ -235,27 +196,21 @@
|
|||||||
name = normalizeHeaderName(name);
|
name = normalizeHeaderName(name);
|
||||||
value = normalizeHeaderValue(value);
|
value = normalizeHeaderValue(value);
|
||||||
const values = this._values.get(name);
|
const values = this._values.get(name);
|
||||||
const combinedValue = values && name !== "set-cookie" ? values.join(", ") + ", " + value : value;
|
assertMutableHeaders(this);
|
||||||
if (blockedByGuard(this, name, combinedValue)) return;
|
|
||||||
if (values) values.push(value);
|
if (values) values.push(value);
|
||||||
else this._values.set(name, [value]);
|
else this._values.set(name, [value]);
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(name) {
|
delete(name) {
|
||||||
name = normalizeHeaderName(name);
|
name = normalizeHeaderName(name);
|
||||||
if (this._guard === "immutable") throw new TypeError("Headers are immutable");
|
assertMutableHeaders(this);
|
||||||
if (this._guard === "request-no-cors") {
|
|
||||||
if (forbiddenRequestHeader(name, "") || !["accept", "accept-language", "content-language", "content-type", "range"].includes(name)) return;
|
|
||||||
this._values.delete(name);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (blockedByGuard(this, name, "")) return;
|
|
||||||
this._values.delete(name);
|
this._values.delete(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(name) {
|
get(name) {
|
||||||
const values = this._values.get(normalizeHeaderName(name));
|
name = normalizeHeaderName(name);
|
||||||
return values ? values.join(", ") : null;
|
const values = this._values.get(name);
|
||||||
|
return values ? values.join(name === "cookie" ? "; " : ", ") : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSetCookie() {
|
getSetCookie() {
|
||||||
@@ -268,10 +223,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
set(name, value) {
|
set(name, value) {
|
||||||
name = normalizeHeaderName(name);
|
name = normalizeHeaderName(name);
|
||||||
value = normalizeHeaderValue(value);
|
value = normalizeHeaderValue(value);
|
||||||
if (blockedByGuard(this, name, value)) return;
|
assertMutableHeaders(this);
|
||||||
this._values.set(name, [value]);
|
this._values.set(name, [value]);
|
||||||
}
|
}
|
||||||
|
|
||||||
keys() { return createHeadersIterator(this, "key"); }
|
keys() { return createHeadersIterator(this, "key"); }
|
||||||
@@ -484,7 +439,7 @@
|
|||||||
}
|
}
|
||||||
this._duplex = "half";
|
this._duplex = "half";
|
||||||
const headerInit = init.headers !== undefined ? init.headers : (source ? source.headers : undefined);
|
const headerInit = init.headers !== undefined ? init.headers : (source ? source.headers : undefined);
|
||||||
this._headers = new Headers(headerInit, this._mode === "no-cors" ? "request-no-cors" : "request");
|
this._headers = new Headers(headerInit, "request");
|
||||||
let body = init.body !== undefined ? init.body : (source ? source._bodyInit : null);
|
let body = init.body !== undefined ? init.body : (source ? source._bodyInit : null);
|
||||||
if (source && !bodyWasOverridden && source.body) body = transferReadableStream(source.body);
|
if (source && !bodyWasOverridden && source.body) body = transferReadableStream(source.body);
|
||||||
const bodyIsStream = typeof ReadableStream !== "undefined" && body instanceof ReadableStream;
|
const bodyIsStream = typeof ReadableStream !== "undefined" && body instanceof ReadableStream;
|
||||||
|
|||||||
@@ -302,6 +302,23 @@ func (r *registry) Open(fingerprint string, proxyHandler func(*http.Request) (*u
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, header := range metadata.Headers {
|
for _, header := range metadata.Headers {
|
||||||
|
// Ignore transport controls unsupported by Node/Undici rather than
|
||||||
|
// applying a browser forbidden-header list or failing the whole request.
|
||||||
|
switch strings.ToLower(header[0]) {
|
||||||
|
case "expect", "keep-alive", "transfer-encoding", "upgrade", "content-length":
|
||||||
|
// Content-Length is generated from the buffered request body below.
|
||||||
|
continue
|
||||||
|
case "connection":
|
||||||
|
if value := strings.ToLower(header[1]); value != "close" && value != "keep-alive" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case "trailer":
|
||||||
|
// Fetch supplies a declaration, not a Go Request.Trailer payload.
|
||||||
|
// Keep its lower-case wire name so Go's writer does not omit it as
|
||||||
|
// the canonical Trailer field it normally generates itself.
|
||||||
|
httpRequest.Header["trailer"] = append(httpRequest.Header["trailer"], header[1])
|
||||||
|
continue
|
||||||
|
}
|
||||||
httpRequest.Header.Add(header[0], header[1])
|
httpRequest.Header.Add(header[0], header[1])
|
||||||
}
|
}
|
||||||
if host := httpRequest.Header.Get("Host"); host != "" {
|
if host := httpRequest.Header.Get("Host"); host != "" {
|
||||||
|
|||||||
+26
-2
@@ -9,7 +9,7 @@ The files under `fetch/` are copied from the official
|
|||||||
|
|
||||||
Gopeed executes applicable worker-style Fetch API tests at the extension
|
Gopeed executes applicable worker-style Fetch API tests at the extension
|
||||||
JavaScript boundary. The checked-in runner currently executes unchanged WPT
|
JavaScript boundary. The checked-in runner currently executes unchanged WPT
|
||||||
tests for Headers, no-CORS header guards, Request construction and body
|
tests for Headers syntax and iteration, Request construction and body
|
||||||
consumption, ReadableStream bodies, keepalive, Request errors, Response
|
consumption, ReadableStream bodies, keepalive, Request errors, Response
|
||||||
construction and body consumption, immutable network response headers, and
|
construction and body consumption, immutable network response headers, and
|
||||||
the Response static constructors.
|
the Response static constructors.
|
||||||
@@ -19,10 +19,34 @@ a claim that Gopeed is a browser. Browser-only suites that require a document,
|
|||||||
CORS origin enforcement or preflight, CSP, Mixed Content, Service Workers,
|
CORS origin enforcement or preflight, CSP, Mixed Content, Service Workers,
|
||||||
browser HTTP cache, navigation, browser authentication UI, or WPT's
|
browser HTTP cache, navigation, browser authentication UI, or WPT's
|
||||||
multi-origin server infrastructure are intentionally excluded. Gopeed also
|
multi-origin server infrastructure are intentionally excluded. Gopeed also
|
||||||
keeps one extension-specific behavior: `redirect: "manual"` exposes the HTTP
|
keeps extension-specific behavior: `redirect: "manual"` exposes the HTTP
|
||||||
redirect response so download extensions can inspect `Location`, instead of
|
redirect response so download extensions can inspect `Location`, instead of
|
||||||
returning a browser `opaqueredirect` response.
|
returning a browser `opaqueredirect` response.
|
||||||
|
|
||||||
|
Header permissions follow [Node/Undici's server-side Fetch API](https://github.com/nodejs/undici/blob/main/README.md#forbidden-and-safelisted-header-names). Gopeed does not
|
||||||
|
apply browser forbidden request/response header lists or no-CORS safelists.
|
||||||
|
Extensions may supply authentication, referrer, origin, host, proxy and fetch
|
||||||
|
metadata headers, and read `Set-Cookie` response headers. Network response
|
||||||
|
headers remain immutable; invalid header syntax still raises an error.
|
||||||
|
Repeated `Cookie` values are combined with `; `, as in Node/Undici.
|
||||||
|
|
||||||
|
The runner excludes the browser-only `headers-no-cors.any.js` and
|
||||||
|
`headers-forbidden-override.any.js` suites, the filtering assertions within
|
||||||
|
`request-headers.any.js`, and the forbidden-response-cookie assertion within
|
||||||
|
`header-setcookie.any.js`. Gopeed's `extension_headers_test.go` replaces those
|
||||||
|
restrictions with integration tests that check headers actually received by
|
||||||
|
an HTTP server through fetch and XMLHttpRequest, plus header mutations,
|
||||||
|
cloning and response cookies. The upstream test files remain unchanged.
|
||||||
|
|
||||||
|
Gopeed deliberately ignores transport controls that Node/Undici rejects:
|
||||||
|
`Expect`, `Keep-Alive`, `Transfer-Encoding`, `Upgrade`, and `Connection` values
|
||||||
|
other than `close` or `keep-alive`. `Content-Length` is generated from the
|
||||||
|
actual request body, ignoring a supplied length. Explicit `Host` and
|
||||||
|
`Sec-Fetch-Mode` values are preserved rather than replaced by defaults.
|
||||||
|
These transport behaviors are also covered by integration tests.
|
||||||
|
`credentials: "omit"` disables automatic CookieJar cookies but preserves
|
||||||
|
explicit headers; Gopeed retains its extension CookieJar support.
|
||||||
|
|
||||||
`wpt_harness.js` is a small Goja adapter maintained by Gopeed; it is not copied
|
`wpt_harness.js` is a small Goja adapter maintained by Gopeed; it is not copied
|
||||||
from WPT. Test logic below `fetch/` is unchanged; the checked-in copy may only
|
from WPT. Test logic below `fetch/` is unchanged; the checked-in copy may only
|
||||||
normalize a final newline.
|
normalize a final newline.
|
||||||
@@ -20,9 +20,6 @@ var wptFetchUtils string
|
|||||||
//go:embed testdata/wpt/fetch/api/request/request-error.js
|
//go:embed testdata/wpt/fetch/api/request/request-error.js
|
||||||
var wptRequestError string
|
var wptRequestError string
|
||||||
|
|
||||||
//go:embed testdata/wpt/fetch/api/cors/resources/not-cors-safelisted.json
|
|
||||||
var wptNoCORSSafelistedHeaders []byte
|
|
||||||
|
|
||||||
//go:embed testdata/wpt/fetch/api/headers/*.any.js
|
//go:embed testdata/wpt/fetch/api/headers/*.any.js
|
||||||
var wptHeaders embed.FS
|
var wptHeaders embed.FS
|
||||||
|
|
||||||
@@ -30,31 +27,19 @@ var wptHeaders embed.FS
|
|||||||
var wptRequestResponse embed.FS
|
var wptRequestResponse embed.FS
|
||||||
|
|
||||||
func TestWPTHeaders(t *testing.T) {
|
func TestWPTHeaders(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
|
||||||
response.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = response.Write(wptNoCORSSafelistedHeaders)
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
|
|
||||||
tests := []string{
|
tests := []string{
|
||||||
"header-setcookie.any.js",
|
"header-setcookie.any.js",
|
||||||
"headers-basic.any.js",
|
"headers-basic.any.js",
|
||||||
"headers-casing.any.js",
|
"headers-casing.any.js",
|
||||||
"headers-combine.any.js",
|
"headers-combine.any.js",
|
||||||
"headers-errors.any.js",
|
"headers-errors.any.js",
|
||||||
"headers-forbidden-override.any.js",
|
|
||||||
"headers-normalize.any.js",
|
"headers-normalize.any.js",
|
||||||
"headers-no-cors.any.js",
|
|
||||||
"headers-record.any.js",
|
"headers-record.any.js",
|
||||||
"headers-structure.any.js",
|
"headers-structure.any.js",
|
||||||
}
|
}
|
||||||
for _, name := range tests {
|
for _, name := range tests {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
setup := ""
|
runWPTFile(t, wptHeaders, path.Join("testdata/wpt/fetch/api/headers", name), "")
|
||||||
if name == "headers-no-cors.any.js" {
|
|
||||||
setup = fmt.Sprintf("globalThis.location = new URL(%q);", server.URL+"/fetch/api/headers/test.any.js")
|
|
||||||
}
|
|
||||||
runWPTFile(t, wptHeaders, path.Join("testdata/wpt/fetch/api/headers", name), setup)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,7 +95,30 @@ func runWPTFile(t *testing.T, files embed.FS, name, setup string) {
|
|||||||
}
|
}
|
||||||
runtime := engine.NewEngine(nil)
|
runtime := engine.NewEngine(nil)
|
||||||
t.Cleanup(runtime.Close)
|
t.Cleanup(runtime.Close)
|
||||||
value, err := runtime.RunString(setup + "\n" + wptHarness + "\n" + wptFetchUtils + "\n" + wptRequestError + "\n" + string(source) + "\n__wptFinish();")
|
adaptation := ""
|
||||||
|
if name == "testdata/wpt/fetch/api/request/request-headers.any.js" {
|
||||||
|
// Node-style header permissions are covered by extension integration tests.
|
||||||
|
// Keep upstream tests unchanged and exclude browser-only restrictions.
|
||||||
|
adaptation = `
|
||||||
|
const upstreamTest = globalThis.test;
|
||||||
|
globalThis.test = function (callback, name) {
|
||||||
|
if (name.startsWith('Adding invalid request header ') ||
|
||||||
|
name.startsWith('Adding invalid no-cors request header ') ||
|
||||||
|
name.startsWith('Check that request constructor is filtering headers ') ||
|
||||||
|
name.startsWith('Check that no-cors request constructor is filtering headers ')) return;
|
||||||
|
upstreamTest(callback, name);
|
||||||
|
};
|
||||||
|
`
|
||||||
|
} else if name == "testdata/wpt/fetch/api/headers/header-setcookie.any.js" {
|
||||||
|
adaptation = `
|
||||||
|
const upstreamTest = globalThis.test;
|
||||||
|
globalThis.test = function (callback, name) {
|
||||||
|
if (name === 'Set-Cookie is a forbidden response header') return;
|
||||||
|
upstreamTest(callback, name);
|
||||||
|
};
|
||||||
|
`
|
||||||
|
}
|
||||||
|
value, err := runtime.RunString(setup + "\n" + wptHarness + "\n" + wptFetchUtils + "\n" + wptRequestError + "\n" + adaptation + "\n" + string(source) + "\n__wptFinish();")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||||
"github.com/GopeedLab/gopeed/internal/logger"
|
"github.com/GopeedLab/gopeed/internal/logger"
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||||
@@ -733,6 +734,14 @@ type OnDoneContext struct {
|
|||||||
// only some fields can be modified, such as request info.
|
// only some fields can be modified, such as request info.
|
||||||
type ExtensionTask struct {
|
type ExtensionTask struct {
|
||||||
*Task
|
*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.
|
// OnErrorExtensionTask adds error-recovery controls to ExtensionTask.
|
||||||
@@ -750,7 +759,14 @@ func cloneExtensionTask(task *Task) *Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newExtensionTask(task *Task) *ExtensionTask {
|
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 {
|
func newOnStartExtensionTask(task *Task) *ExtensionTask {
|
||||||
@@ -768,11 +784,6 @@ func newOnDoneExtensionTask(task *Task) *Task {
|
|||||||
return cloneExtensionTask(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 {
|
func (t *OnErrorExtensionTask) Continue() error {
|
||||||
return t.download.Continue(&TaskFilter{
|
return t.download.Continue(&TaskFilter{
|
||||||
IDs: []string{t.ID},
|
IDs: []string{t.ID},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -14,10 +14,12 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||||
"github.com/GopeedLab/gopeed/internal/logger"
|
"github.com/GopeedLab/gopeed/internal/logger"
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
||||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||||
|
httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,13 +40,13 @@ func TestDownloader_InstallExtensionByFolder(t *testing.T) {
|
|||||||
t.Fatal("resolve error")
|
t.Fatal("resolve error")
|
||||||
}
|
}
|
||||||
if req.Labels["replaced"] != "true" || req.Labels["modified"] != "true" {
|
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 {
|
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 {
|
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) {
|
func TestDownloader_Extension_OnStart(t *testing.T) {
|
||||||
downloadAndCheck := func(req *base.Request) {
|
downloadAndCheck := func(req *base.Request) {
|
||||||
setupDownloader(func(downloader *Downloader) {
|
setupDownloader(func(downloader *Downloader) {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ type ResolveResult struct {
|
|||||||
Res *base.Resource `json:"res"`
|
Res *base.Resource `json:"res"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task represents a download unit.
|
||||||
|
// Options flow path:
|
||||||
|
// REST Request (model.CreateTask/ResolveTask) -> pkg/download Downloader -> Task.Meta.Opts (base.Options) -> Fetcher.Resolve/Start (e.g. internal/protocol/http/fetcher.go)
|
||||||
type Task struct {
|
type Task struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
@@ -32,6 +35,10 @@ type Task struct {
|
|||||||
Progress *Progress `json:"progress"`
|
Progress *Progress `json:"progress"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
// DoneAt is the time when the task completed; nil if it has never
|
||||||
|
// finished. A done task never leaves the done state, so the value is
|
||||||
|
// written once and kept.
|
||||||
|
DoneAt *time.Time `json:"doneAt"`
|
||||||
|
|
||||||
producer production.Source
|
producer production.Source
|
||||||
producerReceived int64
|
producerReceived int64
|
||||||
@@ -134,6 +141,12 @@ func (t *Task) MarshalJSON() ([]byte, error) {
|
|||||||
func (t *Task) updateStatus(status base.Status) {
|
func (t *Task) updateStatus(status base.Status) {
|
||||||
t.UpdatedAt = time.Now()
|
t.UpdatedAt = time.Now()
|
||||||
t.Status = status
|
t.Status = status
|
||||||
|
// A done task never leaves the done state, so there is no branch to clear
|
||||||
|
// DoneAt on other transitions.
|
||||||
|
if status == base.DownloadStatusDone {
|
||||||
|
doneAt := time.Now()
|
||||||
|
t.DoneAt = &doneAt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Task) clone() *Task {
|
func (t *Task) clone() *Task {
|
||||||
|
|||||||
+6
-3
@@ -1,7 +1,10 @@
|
|||||||
gopeed.events.onResolve(async function (ctx) {
|
gopeed.events.onResolve(async function (ctx) {
|
||||||
await ctx.req.setLabels({ replaced: "true", removed: "true" });
|
for (const method of ['setUrl', 'setLabels', 'putLabel', 'delLabel', 'setMethod', 'putHeader', 'setTrackers']) {
|
||||||
await ctx.req.putLabel("modified", "true");
|
if (typeof ctx.req[method] !== 'undefined') throw new Error(`onResolve exposes ${method}`);
|
||||||
await ctx.req.delLabel("removed");
|
}
|
||||||
|
ctx.req.labels = { replaced: "true", removed: "true" };
|
||||||
|
ctx.req.labels.modified = "true";
|
||||||
|
delete ctx.req.labels.removed;
|
||||||
ctx.res = {
|
ctx.res = {
|
||||||
name: "test",
|
name: "test",
|
||||||
files: Array(2).fill(true).map((_, i) => ({
|
files: Array(2).fill(true).map((_, i) => ({
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ gopeed.events.onError(async function (ctx) {
|
|||||||
return;
|
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('started', 'true');
|
||||||
await req.putLabel('rebuilt', 'true');
|
await req.putLabel('rebuilt', 'true');
|
||||||
await ctx.task.continue();
|
await ctx.task.continue();
|
||||||
|
|||||||
+1
-1
@@ -41,6 +41,6 @@ gopeed.events.onError(async function (ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await req.putLabel("recovered", "true");
|
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();
|
await ctx.task.continue();
|
||||||
});
|
});
|
||||||
+1
-1
@@ -46,7 +46,7 @@ gopeed.events.onError(async function (ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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('started', 'true');
|
||||||
await req.putLabel('rebuilt', 'true');
|
await req.putLabel('rebuilt', 'true');
|
||||||
await ctx.task.continue();
|
await ctx.task.continue();
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
gopeed.events.onError(async function (ctx) {
|
gopeed.events.onError(async function (ctx) {
|
||||||
gopeed.logger.info("url", ctx.task.meta.req.url);
|
gopeed.logger.info("url", ctx.task.meta.req.url);
|
||||||
gopeed.logger.info("error", ctx.error);
|
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();
|
await ctx.task.continue();
|
||||||
});
|
});
|
||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
gopeed.events.onStart(async function (ctx) {
|
gopeed.events.onStart(async function (ctx) {
|
||||||
gopeed.logger.info("url", ctx.task.meta.req.url);
|
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');
|
await ctx.task.meta.req.putLabel('modified', 'true');
|
||||||
});
|
});
|
||||||
@@ -21,6 +21,7 @@ type WebhookEvent string
|
|||||||
const (
|
const (
|
||||||
WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE"
|
WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE"
|
||||||
WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
|
WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
|
||||||
|
WebhookEventDownloadPause WebhookEvent = "DOWNLOAD_PAUSE"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebhookData is the data sent to webhook URLs
|
// WebhookData is the data sent to webhook URLs
|
||||||
|
|||||||
@@ -814,6 +814,49 @@ func TestWebhook_TestWebhookUrl_VerifyTestPayload(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWebhook_TriggerOnPause(t *testing.T) {
|
||||||
|
receivedData := make(chan *WebhookData, 1)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var data WebhookData
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||||
|
t.Errorf("Failed to decode data: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
receivedData <- &data
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
setupWebhookTest(t, func(downloader *Downloader) {
|
||||||
|
cfg, _ := downloader.GetConfig()
|
||||||
|
cfg.Webhook = &base.WebhookConfig{
|
||||||
|
Enable: true,
|
||||||
|
URLs: []string{server.URL},
|
||||||
|
}
|
||||||
|
downloader.PutConfig(cfg)
|
||||||
|
|
||||||
|
task := NewTask()
|
||||||
|
task.Protocol = "http"
|
||||||
|
task.Meta = &mockFetcherMeta
|
||||||
|
|
||||||
|
downloader.triggerWebhooks(WebhookEventDownloadPause, task, nil)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case data := <-receivedData:
|
||||||
|
if data.Event != WebhookEventDownloadPause {
|
||||||
|
t.Errorf("Expected event 'DOWNLOAD_PAUSE', got '%s'", data.Event)
|
||||||
|
}
|
||||||
|
if data.Payload == nil || data.Payload.Task == nil {
|
||||||
|
t.Error("Expected payload.task to be present")
|
||||||
|
} else if data.Payload.Task.ID != task.ID {
|
||||||
|
t.Errorf("Expected task ID '%s', got '%s'", task.ID, data.Payload.Task.ID)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Error("Timeout waiting for webhook")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
|
func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
|
||||||
defaultDownloader.Setup()
|
defaultDownloader.Setup()
|
||||||
defaultDownloader.cfg.StorageDir = ".test_storage"
|
defaultDownloader.cfg.StorageDir = ".test_storage"
|
||||||
|
|||||||
+67
-7
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
"github.com/GopeedLab/gopeed/pkg/download"
|
"github.com/GopeedLab/gopeed/pkg/download"
|
||||||
|
phttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||||
"github.com/google/jsonschema-go/jsonschema"
|
"github.com/google/jsonschema-go/jsonschema"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
@@ -65,12 +66,13 @@ type btRequestExtra struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type httpTaskExtra struct {
|
type httpTaskExtra struct {
|
||||||
Connections int `json:"connections,omitempty" jsonschema:"Number of HTTP download connections"`
|
Connections int `json:"connections,omitempty" jsonschema:"Number of HTTP download connections"`
|
||||||
AutoTorrent *bool `json:"autoTorrent,omitempty" jsonschema:"Automatically create a BitTorrent task after downloading a torrent file"`
|
AutoTorrent *bool `json:"autoTorrent,omitempty" jsonschema:"Automatically create a BitTorrent task after downloading a torrent file"`
|
||||||
DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload,omitempty" jsonschema:"Delete the torrent file after automatically creating its BitTorrent task"`
|
DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload,omitempty" jsonschema:"Delete the torrent file after automatically creating its BitTorrent task"`
|
||||||
AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"`
|
AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"`
|
||||||
ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"`
|
ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"`
|
||||||
DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"`
|
DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"`
|
||||||
|
Checksum *checksumOption `json:"checksum,omitempty" jsonschema:"Optional checksum verification settings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadRequest is the MCP-facing form of base.Request. The REST model uses
|
// downloadRequest is the MCP-facing form of base.Request. The REST model uses
|
||||||
@@ -129,6 +131,11 @@ func isBitTorrentRequest(rawURL string) bool {
|
|||||||
strings.HasPrefix(upperURL, "DATA:APPLICATION/X-BITTORRENT;BASE64,")
|
strings.HasPrefix(upperURL, "DATA:APPLICATION/X-BITTORRENT;BASE64,")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type checksumOption struct {
|
||||||
|
Algorithm string `json:"algorithm" jsonschema:"Checksum algorithm: md5, sha1, or sha256"`
|
||||||
|
Expected string `json:"expected" jsonschema:"Expected hex-encoded checksum hash"`
|
||||||
|
}
|
||||||
|
|
||||||
// downloadOptions is the MCP-facing form of base.Options. Only HTTP currently
|
// downloadOptions is the MCP-facing form of base.Options. Only HTTP currently
|
||||||
// defines protocol-specific task options, so Extra has one concrete schema.
|
// defines protocol-specific task options, so Extra has one concrete schema.
|
||||||
type downloadOptions struct {
|
type downloadOptions struct {
|
||||||
@@ -186,12 +193,31 @@ func (o *downloadOptions) baseOptions() *base.Options {
|
|||||||
if o == nil {
|
if o == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
var extra any
|
||||||
|
if o.Extra != nil {
|
||||||
|
var checksum *phttp.ChecksumOption
|
||||||
|
if o.Extra.Checksum != nil {
|
||||||
|
checksum = &phttp.ChecksumOption{
|
||||||
|
Algorithm: o.Extra.Checksum.Algorithm,
|
||||||
|
Expected: o.Extra.Checksum.Expected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extra = &phttp.OptsExtra{
|
||||||
|
Connections: o.Extra.Connections,
|
||||||
|
AutoTorrent: o.Extra.AutoTorrent,
|
||||||
|
DeleteTorrentAfterDownload: o.Extra.DeleteTorrentAfterDownload,
|
||||||
|
AutoExtract: o.Extra.AutoExtract,
|
||||||
|
ArchivePassword: o.Extra.ArchivePassword,
|
||||||
|
DeleteAfterExtract: o.Extra.DeleteAfterExtract,
|
||||||
|
Checksum: checksum,
|
||||||
|
}
|
||||||
|
}
|
||||||
return &base.Options{
|
return &base.Options{
|
||||||
Name: o.Name,
|
Name: o.Name,
|
||||||
Path: o.Path,
|
Path: o.Path,
|
||||||
AsDefaultPath: o.AsDefaultPath,
|
AsDefaultPath: o.AsDefaultPath,
|
||||||
SelectFiles: o.SelectFiles,
|
SelectFiles: o.SelectFiles,
|
||||||
Extra: o.Extra,
|
Extra: extra,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +296,13 @@ type getTaskStatsOutput struct {
|
|||||||
Stats any `json:"stats"`
|
Stats any `json:"stats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type getDownloadSpeedInput struct{}
|
||||||
|
|
||||||
|
type getDownloadSpeedOutput struct {
|
||||||
|
TotalSpeed int64 `json:"totalSpeed"`
|
||||||
|
ActiveTaskCount int `json:"activeTaskCount"`
|
||||||
|
}
|
||||||
|
|
||||||
type taskActionOutput struct {
|
type taskActionOutput struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
@@ -416,6 +449,33 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) {
|
|||||||
return nil, &getTaskStatsOutput{Stats: stats}, nil
|
return nil, &getTaskStatsOutput{Stats: stats}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mcp.AddTool(server, &mcp.Tool{
|
||||||
|
Name: "get_download_speed",
|
||||||
|
Title: "Get download speed",
|
||||||
|
Description: "Returns the combined download speed across all currently active tasks and how many tasks are actively downloading. Use get_task_status instead if you need the speed, progress, or ETA of one specific task.",
|
||||||
|
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closedWorld},
|
||||||
|
}, func(_ context.Context, _ *mcp.CallToolRequest, _ *getDownloadSpeedInput) (*mcp.CallToolResult, *getDownloadSpeedOutput, error) {
|
||||||
|
allTasks := downloader.GetTasks()
|
||||||
|
var totalSpeed int64
|
||||||
|
activeCount := 0
|
||||||
|
|
||||||
|
for _, task := range allTasks {
|
||||||
|
status, err := downloader.RuntimeStatus(task.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if status.Status == base.DownloadStatusRunning {
|
||||||
|
activeCount++
|
||||||
|
totalSpeed += status.Speed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, &getDownloadSpeedOutput{
|
||||||
|
TotalSpeed: totalSpeed,
|
||||||
|
ActiveTaskCount: activeCount,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
registerTaskAction(server, downloader, "pause_task", "Pause task", "Pause one Gopeed task.", &nondestructive, &closedWorld, downloader.Pause)
|
registerTaskAction(server, downloader, "pause_task", "Pause task", "Pause one Gopeed task.", &nondestructive, &closedWorld, downloader.Pause)
|
||||||
registerTaskAction(server, downloader, "continue_task", "Continue task", "Continue a paused or failed Gopeed task.", &nondestructive, &openWorld, downloader.Continue)
|
registerTaskAction(server, downloader, "continue_task", "Continue task", "Continue a paused or failed Gopeed task.", &nondestructive, &openWorld, downloader.Continue)
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ func TestStreamableHTTPTools(t *testing.T) {
|
|||||||
"continue_task",
|
"continue_task",
|
||||||
"create_task",
|
"create_task",
|
||||||
"delete_task",
|
"delete_task",
|
||||||
|
"get_download_speed",
|
||||||
"get_task",
|
"get_task",
|
||||||
"get_task_stats",
|
"get_task_stats",
|
||||||
"get_task_status",
|
"get_task_status",
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package mcpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/download"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetDownloadSpeedTool(t *testing.T) {
|
||||||
|
// Serve a test payload that takes a little bit of time or streams bytes
|
||||||
|
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Length", "1048576")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
chunk := make([]byte, 8192)
|
||||||
|
for i := 0; i < 128; i++ {
|
||||||
|
_, _ = w.Write(chunk)
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer testServer.Close()
|
||||||
|
|
||||||
|
downloader := download.NewDownloader(&download.DownloaderConfig{
|
||||||
|
Storage: download.NewMemStorage(),
|
||||||
|
StorageDir: t.TempDir(),
|
||||||
|
})
|
||||||
|
if err := downloader.Setup(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer downloader.Close()
|
||||||
|
|
||||||
|
httpServer := httptest.NewServer(NewHandler(downloader))
|
||||||
|
defer httpServer.Close()
|
||||||
|
|
||||||
|
client := mcp.NewClient(&mcp.Implementation{Name: "gopeed-test", Version: "1.0.0"}, nil)
|
||||||
|
session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
// 1. Zero tasks test (returns 0 totalSpeed and 0 activeTaskCount)
|
||||||
|
result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||||
|
Name: "get_download_speed",
|
||||||
|
Arguments: map[string]any{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("get_download_speed returned error: %+v", result.Content)
|
||||||
|
}
|
||||||
|
structured, ok := result.StructuredContent.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("structured content type = %T", result.StructuredContent)
|
||||||
|
}
|
||||||
|
if activeCount, ok := structured["activeTaskCount"].(float64); !ok || activeCount != 0 {
|
||||||
|
t.Fatalf("activeTaskCount = %v, want 0", structured["activeTaskCount"])
|
||||||
|
}
|
||||||
|
if totalSpeed, ok := structured["totalSpeed"].(float64); !ok || totalSpeed != 0 {
|
||||||
|
t.Fatalf("totalSpeed = %v, want 0", structured["totalSpeed"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Start a real download task
|
||||||
|
taskID, err := downloader.CreateDirect(&base.Request{
|
||||||
|
URL: testServer.URL,
|
||||||
|
}, &base.Options{
|
||||||
|
Path: t.TempDir(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = downloader.Delete(&download.TaskFilter{IDs: []string{taskID}}, true)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Query get_download_speed while downloading
|
||||||
|
result, err = session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||||
|
Name: "get_download_speed",
|
||||||
|
Arguments: map[string]any{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("get_download_speed returned error: %+v", result.Content)
|
||||||
|
}
|
||||||
|
structured, ok = result.StructuredContent.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("structured content type = %T", result.StructuredContent)
|
||||||
|
}
|
||||||
|
if _, ok := structured["activeTaskCount"].(float64); !ok {
|
||||||
|
t.Fatalf("activeTaskCount missing or wrong type: %v", structured["activeTaskCount"])
|
||||||
|
}
|
||||||
|
if _, ok := structured["totalSpeed"].(float64); !ok {
|
||||||
|
t.Fatalf("totalSpeed missing or wrong type: %v", structured["totalSpeed"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,13 @@ type ReqExtra struct {
|
|||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChecksumOption specifies an expected checksum to verify against
|
||||||
|
// the completed download. Only supported for HTTP downloads.
|
||||||
|
type ChecksumOption struct {
|
||||||
|
Algorithm string `json:"algorithm"` // "md5" | "sha1" | "sha256"
|
||||||
|
Expected string `json:"expected"` // hex-encoded expected hash
|
||||||
|
}
|
||||||
|
|
||||||
type OptsExtra struct {
|
type OptsExtra struct {
|
||||||
Connections int `json:"connections"`
|
Connections int `json:"connections"`
|
||||||
// AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file
|
// AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file
|
||||||
@@ -21,6 +28,8 @@ type OptsExtra struct {
|
|||||||
ArchivePassword string `json:"archivePassword"`
|
ArchivePassword string `json:"archivePassword"`
|
||||||
// DeleteAfterExtract when true, deletes the archive file after successful extraction
|
// DeleteAfterExtract when true, deletes the archive file after successful extraction
|
||||||
DeleteAfterExtract bool `json:"deleteAfterExtract"`
|
DeleteAfterExtract bool `json:"deleteAfterExtract"`
|
||||||
|
// Checksum verifies the completed file against an expected hash after download
|
||||||
|
Checksum *ChecksumOption `json:"checksum,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats for download
|
// Stats for download
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestOptsExtra_Checksum(t *testing.T) {
|
||||||
|
extra := &OptsExtra{
|
||||||
|
Checksum: &ChecksumOption{
|
||||||
|
Algorithm: "sha256",
|
||||||
|
Expected: "abcdef1234567890",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if extra.Checksum.Algorithm != "sha256" || extra.Checksum.Expected != "abcdef1234567890" {
|
||||||
|
t.Fatalf("unexpected checksum: %+v", extra.Checksum)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,7 +126,7 @@ Guidelines:
|
|||||||
Use the following desktop reference metrics unless a specific screen requires a documented exception:
|
Use the following desktop reference metrics unless a specific screen requires a documented exception:
|
||||||
|
|
||||||
- Reference window: `1024 x 768`
|
- Reference window: `1024 x 768`
|
||||||
- Primary rail width: `64px`
|
- Primary rail width: `78px` (reserves space for native macOS traffic lights)
|
||||||
- Secondary filter sidebar width: `256px`
|
- Secondary filter sidebar width: `256px`
|
||||||
- Main content header height: `80px`
|
- Main content header height: `80px`
|
||||||
- Window drag header height on Windows: `30px`
|
- Window drag header height on Windows: `30px`
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ Appearance synchronization uses `appearance.changed` and currently contains:
|
|||||||
- Theme color
|
- Theme color
|
||||||
- Locale
|
- Locale
|
||||||
|
|
||||||
|
After successful task creation, a child calls the typed
|
||||||
|
`NavigationMethods.showDownloadingTasks` capability before closing. The host
|
||||||
|
routes to the task list and resets its filter to downloading, including any
|
||||||
|
search or selection on an already-mounted list. This does not show or focus a
|
||||||
|
hidden main window. Cancellation and failed creation do not request navigation.
|
||||||
|
|
||||||
State broadcasting must observe the owning Riverpod state centrally. Do not broadcast directly from a settings button callback, because updates can originate from configuration loading, system changes, or future entry points.
|
State broadcasting must observe the owning Riverpod state centrally. Do not broadcast directly from a settings button callback, because updates can originate from configuration loading, system changes, or future entry points.
|
||||||
|
|
||||||
## 6. Storage
|
## 6. Storage
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
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, ); }; };
|
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 */; };
|
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 */; };
|
6085D504396791024CEC496B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8823E31DA135C410E362683E /* Pods_RunnerTests.framework */; };
|
||||||
6243BE8F995F5284DBDD7DE3 /* GopeedLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */; };
|
6243BE8F995F5284DBDD7DE3 /* GopeedLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.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; };
|
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 = "<group>"; };
|
35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedLiveActivityWidget.swift; sourceTree = "<group>"; };
|
||||||
3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedContinuedProcessingManager.swift; sourceTree = "<group>"; };
|
3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedContinuedProcessingManager.swift; sourceTree = "<group>"; };
|
||||||
|
B30000000000000000000002 /* GopeedTaskEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedTaskEvent.swift; sourceTree = "<group>"; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
52A13633B52EDC9D3F479DFD /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
52A13633B52EDC9D3F479DFD /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
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 = "<group>"; };
|
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 = "<group>"; };
|
||||||
@@ -286,6 +288,7 @@
|
|||||||
09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */,
|
09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */,
|
||||||
EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */,
|
EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */,
|
||||||
3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */,
|
3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */,
|
||||||
|
B30000000000000000000002 /* GopeedTaskEvent.swift */,
|
||||||
);
|
);
|
||||||
path = Runner;
|
path = Runner;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -607,6 +610,7 @@
|
|||||||
1E29080079AAFD1CF2C71262 /* GopeedDownloadAttributes.swift in Sources */,
|
1E29080079AAFD1CF2C71262 /* GopeedDownloadAttributes.swift in Sources */,
|
||||||
032447D610CFAF8A75642212 /* GopeedLiveActivityManager.swift in Sources */,
|
032447D610CFAF8A75642212 /* GopeedLiveActivityManager.swift in Sources */,
|
||||||
5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */,
|
5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */,
|
||||||
|
B30000000000000000000001 /* GopeedTaskEvent.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
|
|
||||||
// MARK: - BGCPT state
|
// MARK: - BGCPT state
|
||||||
|
|
||||||
private var registeredIdentifiers = Set<String>()
|
|
||||||
private var pendingTaskIDs = Set<String>()
|
private var pendingTaskIDs = Set<String>()
|
||||||
|
private var foregroundStartTokens: [String: UUID] = [:]
|
||||||
private struct ExpiringTaskState {
|
private struct ExpiringTaskState {
|
||||||
let identifier: String
|
let identifier: String
|
||||||
let generation: UUID
|
let generation: UUID
|
||||||
@@ -167,16 +167,11 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
// Pre-mark task.start so the Objective-C forwarder can
|
// Pre-mark task.start so the Objective-C forwarder can
|
||||||
// immediately suppress the custom ActivityKit Live Activity.
|
// immediately suppress the custom ActivityKit Live Activity.
|
||||||
if
|
if
|
||||||
let data = payload.data(using: .utf8),
|
let event = GopeedTaskEvent.decode(payload),
|
||||||
let json = try? JSONSerialization.jsonObject(
|
event.type == .start
|
||||||
with: data
|
|
||||||
) as? [String: Any],
|
|
||||||
let type = json["type"] as? String,
|
|
||||||
type == "task.start",
|
|
||||||
let taskID = json["taskId"] as? String
|
|
||||||
{
|
{
|
||||||
setTaskOwnership(
|
setTaskOwnership(
|
||||||
taskID: taskID,
|
taskID: event.taskID,
|
||||||
handled: true
|
handled: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -198,33 +193,19 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard
|
guard let event = GopeedTaskEvent.decode(payload) else {
|
||||||
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(
|
print(
|
||||||
"ContinuedProcessing: invalid event"
|
"ContinuedProcessing: invalid event"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let name =
|
let taskID = event.taskID
|
||||||
json["name"] as? String
|
let name = event.name
|
||||||
?? "Download"
|
|
||||||
|
|
||||||
switch type {
|
switch event.type {
|
||||||
|
|
||||||
case "task.start":
|
case .start:
|
||||||
progressRecoveryTokens.removeValue(
|
progressRecoveryTokens.removeValue(
|
||||||
forKey: taskID
|
forKey: taskID
|
||||||
)
|
)
|
||||||
@@ -234,7 +215,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
name: name
|
name: name
|
||||||
)
|
)
|
||||||
|
|
||||||
case "task.progress":
|
case .progress:
|
||||||
if let generation =
|
if let generation =
|
||||||
taskGenerations[taskID] {
|
taskGenerations[taskID] {
|
||||||
updateProgress(
|
updateProgress(
|
||||||
@@ -249,7 +230,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "task.pause":
|
case .pause:
|
||||||
if let expiring =
|
if let expiring =
|
||||||
expiringTasks[taskID] {
|
expiringTasks[taskID] {
|
||||||
finishExpirationCleanup(
|
finishExpirationCleanup(
|
||||||
@@ -265,7 +246,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "task.done":
|
case .done:
|
||||||
finishTask(
|
finishTask(
|
||||||
taskID: taskID,
|
taskID: taskID,
|
||||||
success: true,
|
success: true,
|
||||||
@@ -273,7 +254,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
"Download complete"
|
"Download complete"
|
||||||
)
|
)
|
||||||
|
|
||||||
case "task.error":
|
case .error:
|
||||||
finishTask(
|
finishTask(
|
||||||
taskID: taskID,
|
taskID: taskID,
|
||||||
success: false,
|
success: false,
|
||||||
@@ -281,7 +262,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
"Download failed"
|
"Download failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
case "task.delete":
|
case .delete:
|
||||||
finishTask(
|
finishTask(
|
||||||
taskID: taskID,
|
taskID: taskID,
|
||||||
success: true,
|
success: true,
|
||||||
@@ -289,12 +270,11 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
"Download removed"
|
"Download removed"
|
||||||
)
|
)
|
||||||
|
|
||||||
default:
|
case .other:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Recover active task after setting changes
|
// MARK: - Recover active task after setting changes
|
||||||
|
|
||||||
private func recoverTaskFromProgress(
|
private func recoverTaskFromProgress(
|
||||||
@@ -307,6 +287,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
!pendingTaskIDs.contains(taskID),
|
!pendingTaskIDs.contains(taskID),
|
||||||
activeTasks[taskID] == nil,
|
activeTasks[taskID] == nil,
|
||||||
expiringTasks[taskID] == nil,
|
expiringTasks[taskID] == nil,
|
||||||
|
foregroundStartTokens[taskID] == nil,
|
||||||
progressRecoveryTokens[taskID] == nil
|
progressRecoveryTokens[taskID] == nil
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
@@ -395,7 +376,8 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
|
|
||||||
guard
|
guard
|
||||||
!pendingTaskIDs.contains(taskID),
|
!pendingTaskIDs.contains(taskID),
|
||||||
activeTasks[taskID] == nil
|
activeTasks[taskID] == nil,
|
||||||
|
foregroundStartTokens[taskID] == nil
|
||||||
else {
|
else {
|
||||||
setTaskOwnership(
|
setTaskOwnership(
|
||||||
taskID: taskID,
|
taskID: taskID,
|
||||||
@@ -404,20 +386,57 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// BGCPT should originate from a foreground user action.
|
// BGCPT must originate from a foreground user action.
|
||||||
// This main-thread check happens only once per task start.
|
// Never synchronously hop from workerQueue to the main queue:
|
||||||
let appIsActive: Bool
|
// 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 {
|
DispatchQueue.main.async { [weak self] in
|
||||||
appIsActive =
|
let appIsActive =
|
||||||
UIApplication.shared
|
UIApplication.shared
|
||||||
.applicationState == .active
|
.applicationState == .active
|
||||||
} else {
|
|
||||||
appIsActive =
|
guard let self else {
|
||||||
DispatchQueue.main.sync {
|
return
|
||||||
UIApplication.shared
|
}
|
||||||
.applicationState == .active
|
|
||||||
}
|
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 {
|
guard appIsActive else {
|
||||||
@@ -434,6 +453,20 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
return
|
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()
|
let generation = UUID()
|
||||||
taskGenerations[taskID] = generation
|
taskGenerations[taskID] = generation
|
||||||
|
|
||||||
@@ -498,8 +531,6 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
registeredIdentifiers.insert(identifier)
|
|
||||||
|
|
||||||
let request =
|
let request =
|
||||||
BGContinuedProcessingTaskRequest(
|
BGContinuedProcessingTaskRequest(
|
||||||
identifier: identifier,
|
identifier: identifier,
|
||||||
@@ -558,7 +589,6 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - System launched task
|
// MARK: - System launched task
|
||||||
|
|
||||||
private func activateTask(
|
private func activateTask(
|
||||||
@@ -901,6 +931,10 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
success: Bool,
|
success: Bool,
|
||||||
finalSubtitle: String
|
finalSubtitle: String
|
||||||
) {
|
) {
|
||||||
|
foregroundStartTokens.removeValue(
|
||||||
|
forKey: taskID
|
||||||
|
)
|
||||||
|
|
||||||
progressRecoveryTokens.removeValue(
|
progressRecoveryTokens.removeValue(
|
||||||
forKey: taskID
|
forKey: taskID
|
||||||
)
|
)
|
||||||
@@ -1115,6 +1149,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
Set(
|
Set(
|
||||||
handledTaskIDsSnapshot()
|
handledTaskIDsSnapshot()
|
||||||
+ Array(taskIdentifiers.keys)
|
+ Array(taskIdentifiers.keys)
|
||||||
|
+ Array(foregroundStartTokens.keys)
|
||||||
+ Array(pendingTaskIDs)
|
+ Array(pendingTaskIDs)
|
||||||
+ Array(activeTasks.keys)
|
+ Array(activeTasks.keys)
|
||||||
+ Array(expiringTasks.keys)
|
+ Array(expiringTasks.keys)
|
||||||
@@ -1140,6 +1175,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
activeTasks.removeAll()
|
activeTasks.removeAll()
|
||||||
|
foregroundStartTokens.removeAll()
|
||||||
pendingTaskIDs.removeAll()
|
pendingTaskIDs.removeAll()
|
||||||
expiringTasks.removeAll()
|
expiringTasks.removeAll()
|
||||||
taskGenerations.removeAll()
|
taskGenerations.removeAll()
|
||||||
|
|||||||
@@ -248,26 +248,22 @@ final class GopeedLiveActivityManager: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard
|
guard let event = GopeedTaskEvent.decode(payload) else {
|
||||||
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")
|
print("LiveActivity: invalid task event payload")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let taskID = event.taskID
|
||||||
|
|
||||||
guard !isTaskSuppressed(taskID) else {
|
guard !isTaskSuppressed(taskID) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let name = json["name"] as? String ?? "Download"
|
let name = event.name
|
||||||
|
|
||||||
switch type {
|
switch event.type {
|
||||||
|
|
||||||
case "task.start":
|
case .start:
|
||||||
let generation =
|
let generation =
|
||||||
startGeneration(
|
startGeneration(
|
||||||
taskID: taskID
|
taskID: taskID
|
||||||
@@ -282,7 +278,7 @@ final class GopeedLiveActivityManager: NSObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "task.progress":
|
case .progress:
|
||||||
guard
|
guard
|
||||||
let generation =
|
let generation =
|
||||||
generationForProgress(
|
generationForProgress(
|
||||||
@@ -298,7 +294,7 @@ final class GopeedLiveActivityManager: NSObject {
|
|||||||
generation: generation
|
generation: generation
|
||||||
)
|
)
|
||||||
|
|
||||||
case "task.pause":
|
case .pause:
|
||||||
if let generation =
|
if let generation =
|
||||||
invalidateGeneration(
|
invalidateGeneration(
|
||||||
taskID: taskID
|
taskID: taskID
|
||||||
@@ -312,7 +308,7 @@ final class GopeedLiveActivityManager: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "task.done":
|
case .done:
|
||||||
if let generation =
|
if let generation =
|
||||||
invalidateGeneration(
|
invalidateGeneration(
|
||||||
taskID: taskID
|
taskID: taskID
|
||||||
@@ -325,9 +321,9 @@ final class GopeedLiveActivityManager: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "task.error":
|
case .error:
|
||||||
let error =
|
let error =
|
||||||
json["error"] as? String
|
event.error
|
||||||
?? "Download failed"
|
?? "Download failed"
|
||||||
|
|
||||||
if let generation =
|
if let generation =
|
||||||
@@ -343,7 +339,7 @@ final class GopeedLiveActivityManager: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "task.delete":
|
case .delete:
|
||||||
if let generation =
|
if let generation =
|
||||||
invalidateGeneration(
|
invalidateGeneration(
|
||||||
taskID: taskID
|
taskID: taskID
|
||||||
@@ -357,7 +353,7 @@ final class GopeedLiveActivityManager: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
case .other:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ class GopeedSiteApi {
|
|||||||
static final instance = GopeedSiteApi._();
|
static final instance = GopeedSiteApi._();
|
||||||
|
|
||||||
static const _host = 'gopeed.com';
|
static const _host = 'gopeed.com';
|
||||||
|
final _storePages = <String, ({StoreExtensionPage page, DateTime expires})>{};
|
||||||
|
final _storeRequests = <String, Future<StoreExtensionPage>>{};
|
||||||
|
|
||||||
Future<List<dynamic>> getReleases({int perPage = 10}) async {
|
Future<List<dynamic>> getReleases({int perPage = 10}) async {
|
||||||
final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()});
|
final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()});
|
||||||
@@ -26,20 +28,52 @@ class GopeedSiteApi {
|
|||||||
StoreExtensionSort sort = StoreExtensionSort.stars,
|
StoreExtensionSort sort = StoreExtensionSort.stars,
|
||||||
StoreSortOrder order = StoreSortOrder.desc,
|
StoreSortOrder order = StoreSortOrder.desc,
|
||||||
String? query,
|
String? query,
|
||||||
|
bool forceRefresh = false,
|
||||||
}) async {
|
}) async {
|
||||||
final json = await _getJson(
|
final params = {
|
||||||
'/api/extensions',
|
'page': page.toString(),
|
||||||
queryParameters: {
|
'limit': limit.clamp(1, 100).toString(),
|
||||||
'page': page.toString(),
|
'sort': sort.name,
|
||||||
'limit': limit.clamp(1, 100).toString(),
|
'order': order.name,
|
||||||
'sort': sort.name,
|
'view': 'summary',
|
||||||
'order': order.name,
|
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
|
||||||
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
|
};
|
||||||
},
|
final key = Uri.https(_host, '/api/extensions', params).toString();
|
||||||
);
|
final now = DateTime.now();
|
||||||
|
_storePages.removeWhere((_, entry) => !now.isBefore(entry.expires));
|
||||||
|
final cached = _storePages[key];
|
||||||
|
if (!forceRefresh && cached != null) return cached.page;
|
||||||
|
final pending = _storeRequests[key];
|
||||||
|
if (pending != null) return pending;
|
||||||
|
final request = _fetchStorePage(params);
|
||||||
|
_storeRequests[key] = request;
|
||||||
|
try {
|
||||||
|
final result = await request;
|
||||||
|
if (_storePages.length >= 32) _storePages.remove(_storePages.keys.first);
|
||||||
|
_storePages[key] = (page: result, expires: DateTime.now().add(const Duration(minutes: 2)));
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
_storeRequests.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StoreExtensionPage> _fetchStorePage(Map<String, String> params) async {
|
||||||
|
final json = await _getJson('/api/extensions', queryParameters: params);
|
||||||
return StoreExtensionPage.fromJson(json as Map<String, dynamic>);
|
return StoreExtensionPage.fromJson(json as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<StoreExtension> getExtension(String id, {String? version}) async {
|
||||||
|
final json = await _getJsonUri(
|
||||||
|
Uri(
|
||||||
|
scheme: 'https',
|
||||||
|
host: _host,
|
||||||
|
pathSegments: ['api', 'extensions', id],
|
||||||
|
queryParameters: {if (version != null && version.isNotEmpty) 'version': version},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return StoreExtension.fromJson(json as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> reportExtensionInstall(String id) async {
|
Future<void> reportExtensionInstall(String id) async {
|
||||||
final uri = Uri.https(_host, '/api/extensions/install');
|
final uri = Uri.https(_host, '/api/extensions/install');
|
||||||
await proxyRequest(
|
await proxyRequest(
|
||||||
@@ -49,8 +83,11 @@ class GopeedSiteApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) async {
|
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) {
|
||||||
final uri = Uri.https(_host, path, queryParameters);
|
return _getJsonUri(Uri.https(_host, path, queryParameters));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _getJsonUri(Uri uri) async {
|
||||||
final Response<String> response = await proxyRequest(uri.toString());
|
final Response<String> response = await proxyRequest(uri.toString());
|
||||||
if (response.data == null || response.data!.isEmpty) {
|
if (response.data == null || response.data!.isEmpty) {
|
||||||
throw Exception('Empty response from $uri');
|
throw Exception('Empty response from $uri');
|
||||||
|
|||||||
@@ -17,6 +17,21 @@ class Options {
|
|||||||
Map<String, dynamic> toJson() => _$OptionsToJson(this);
|
Map<String, dynamic> toJson() => _$OptionsToJson(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class ChecksumOption {
|
||||||
|
String algorithm;
|
||||||
|
String expected;
|
||||||
|
|
||||||
|
ChecksumOption({
|
||||||
|
this.algorithm = '',
|
||||||
|
this.expected = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ChecksumOption.fromJson(Map<String, dynamic> json) => _$ChecksumOptionFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$ChecksumOptionToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class OptsExtraHttp {
|
class OptsExtraHttp {
|
||||||
int connections;
|
int connections;
|
||||||
@@ -25,6 +40,7 @@ class OptsExtraHttp {
|
|||||||
bool? autoExtract;
|
bool? autoExtract;
|
||||||
String archivePassword;
|
String archivePassword;
|
||||||
bool deleteAfterExtract;
|
bool deleteAfterExtract;
|
||||||
|
ChecksumOption? checksum;
|
||||||
|
|
||||||
OptsExtraHttp({
|
OptsExtraHttp({
|
||||||
this.connections = 0,
|
this.connections = 0,
|
||||||
@@ -33,6 +49,7 @@ class OptsExtraHttp {
|
|||||||
this.autoExtract,
|
this.autoExtract,
|
||||||
this.archivePassword = '',
|
this.archivePassword = '',
|
||||||
this.deleteAfterExtract = false,
|
this.deleteAfterExtract = false,
|
||||||
|
this.checksum,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory OptsExtraHttp.fromJson(Map<String, dynamic> json) => _$OptsExtraHttpFromJson(json);
|
factory OptsExtraHttp.fromJson(Map<String, dynamic> json) => _$OptsExtraHttpFromJson(json);
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ Map<String, dynamic> _$OptionsToJson(Options instance) => <String, dynamic>{
|
|||||||
'extra': ?instance.extra,
|
'extra': ?instance.extra,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ChecksumOption _$ChecksumOptionFromJson(Map<String, dynamic> json) => ChecksumOption(
|
||||||
|
algorithm: json['algorithm'] as String? ?? '',
|
||||||
|
expected: json['expected'] as String? ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ChecksumOptionToJson(ChecksumOption instance) => <String, dynamic>{
|
||||||
|
'algorithm': instance.algorithm,
|
||||||
|
'expected': instance.expected,
|
||||||
|
};
|
||||||
|
|
||||||
OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHttp(
|
OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHttp(
|
||||||
connections: (json['connections'] as num?)?.toInt() ?? 0,
|
connections: (json['connections'] as num?)?.toInt() ?? 0,
|
||||||
autoTorrent: json['autoTorrent'] as bool?,
|
autoTorrent: json['autoTorrent'] as bool?,
|
||||||
@@ -29,6 +39,9 @@ OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHtt
|
|||||||
autoExtract: json['autoExtract'] as bool?,
|
autoExtract: json['autoExtract'] as bool?,
|
||||||
archivePassword: json['archivePassword'] as String? ?? '',
|
archivePassword: json['archivePassword'] as String? ?? '',
|
||||||
deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false,
|
deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false,
|
||||||
|
checksum: json['checksum'] == null
|
||||||
|
? null
|
||||||
|
: ChecksumOption.fromJson(json['checksum'] as Map<String, dynamic>),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, dynamic>{
|
Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, dynamic>{
|
||||||
@@ -38,4 +51,5 @@ Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, d
|
|||||||
'autoExtract': ?instance.autoExtract,
|
'autoExtract': ?instance.autoExtract,
|
||||||
'archivePassword': instance.archivePassword,
|
'archivePassword': instance.archivePassword,
|
||||||
'deleteAfterExtract': instance.deleteAfterExtract,
|
'deleteAfterExtract': instance.deleteAfterExtract,
|
||||||
|
'checksum': ?instance.checksum?.toJson(),
|
||||||
};
|
};
|
||||||
@@ -64,6 +64,7 @@ class StoreExtension {
|
|||||||
final List<String> topics;
|
final List<String> topics;
|
||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
final DateTime? updatedAt;
|
final DateTime? updatedAt;
|
||||||
|
final bool hasDetails;
|
||||||
|
|
||||||
StoreExtension({
|
StoreExtension({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -84,6 +85,7 @@ class StoreExtension {
|
|||||||
required this.topics,
|
required this.topics,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
this.updatedAt,
|
this.updatedAt,
|
||||||
|
this.hasDetails = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory StoreExtension.fromJson(Map<String, dynamic> json) {
|
factory StoreExtension.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -106,6 +108,7 @@ class StoreExtension {
|
|||||||
topics: _parseTopics(json['topics']),
|
topics: _parseTopics(json['topics']),
|
||||||
createdAt: _parseDate(json['createdAt']),
|
createdAt: _parseDate(json['createdAt']),
|
||||||
updatedAt: _parseDate(json['updatedAt']),
|
updatedAt: _parseDate(json['updatedAt']),
|
||||||
|
hasDetails: json.containsKey('readme'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class Task {
|
|||||||
Progress progress;
|
Progress progress;
|
||||||
DateTime createdAt;
|
DateTime createdAt;
|
||||||
DateTime updatedAt;
|
DateTime updatedAt;
|
||||||
|
DateTime? doneAt;
|
||||||
|
|
||||||
Task({
|
Task({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -45,6 +46,7 @@ class Task {
|
|||||||
required this.progress,
|
required this.progress,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
|
this.doneAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
|
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Task _$TaskFromJson(Map<String, dynamic> json) => Task(
|
|||||||
progress: Progress.fromJson(json['progress'] as Map<String, dynamic>),
|
progress: Progress.fromJson(json['progress'] as Map<String, dynamic>),
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
|
doneAt: json['doneAt'] == null ? null : DateTime.parse(json['doneAt'] as String),
|
||||||
)..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol']);
|
)..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol']);
|
||||||
|
|
||||||
Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{
|
Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{
|
||||||
@@ -27,6 +28,7 @@ Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{
|
|||||||
'progress': instance.progress.toJson(),
|
'progress': instance.progress.toJson(),
|
||||||
'createdAt': instance.createdAt.toIso8601String(),
|
'createdAt': instance.createdAt.toIso8601String(),
|
||||||
'updatedAt': instance.updatedAt.toIso8601String(),
|
'updatedAt': instance.updatedAt.toIso8601String(),
|
||||||
|
'doneAt': ?instance.doneAt?.toIso8601String(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$StatusEnumMap = {
|
const _$StatusEnumMap = {
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import 'dart:collection';
|
|||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../api/gopeed_site_api.dart';
|
|
||||||
import '../../../api/model/extension.dart';
|
import '../../../api/model/extension.dart';
|
||||||
import '../../../api/model/install_extension.dart';
|
import '../../../api/model/install_extension.dart';
|
||||||
import '../../../api/model/store_extension.dart';
|
import '../../../api/model/store_extension.dart';
|
||||||
import '../../../api/model/switch_extension.dart';
|
import '../../../api/model/switch_extension.dart';
|
||||||
import '../../../api/model/update_extension_settings.dart';
|
import '../../../api/model/update_extension_settings.dart';
|
||||||
import '../../../core/capabilities/app_capabilities.dart';
|
import '../../../core/capabilities/app_capabilities.dart';
|
||||||
|
import 'store_extension_details_provider.dart';
|
||||||
|
|
||||||
final extensionsControllerProvider = AsyncNotifierProvider<ExtensionsController, ExtensionsState>(
|
final extensionsControllerProvider = AsyncNotifierProvider<ExtensionsController, ExtensionsState>(
|
||||||
ExtensionsController.new,
|
ExtensionsController.new,
|
||||||
@@ -121,63 +121,81 @@ class ExtensionsState {
|
|||||||
class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
||||||
static const manualInstallBusyKey = '__manual_install__';
|
static const manualInstallBusyKey = '__manual_install__';
|
||||||
int _devModeCount = 0;
|
int _devModeCount = 0;
|
||||||
|
int _installedRevision = 0;
|
||||||
|
int _storeRevision = 0;
|
||||||
|
Future<void>? _updateCheck;
|
||||||
|
bool _checkUpdatesAgain = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ExtensionsState> build() async {
|
Future<ExtensionsState> build() => _loadInitialData();
|
||||||
var next = const ExtensionsState(loadingInstalled: true, loadingStore: true);
|
|
||||||
|
Future<ExtensionsState> _loadInitialData({bool forceRefresh = false}) async {
|
||||||
|
final next = _current.copyWith(loadingInstalled: true, loadingStore: true);
|
||||||
state = AsyncValue.data(next);
|
state = AsyncValue.data(next);
|
||||||
final installed = await _loadInstalled(refreshUpdates: true, current: next);
|
await Future.wait([
|
||||||
next = state.value ?? installed;
|
_loadInstalled(refreshUpdates: true, current: next),
|
||||||
await _refreshStore(current: next);
|
_refreshStore(current: _current, forceRefresh: forceRefresh),
|
||||||
return state.value ?? next;
|
]);
|
||||||
|
return ref.mounted ? _current : next;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadInitialData() async {
|
Future<void> loadInitialData() async {
|
||||||
state = const AsyncValue.loading();
|
await _loadInitialData(forceRefresh: true);
|
||||||
state = AsyncValue.data(await build());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadInstalled({bool refreshUpdates = false}) async {
|
Future<void> loadInstalled({bool refreshUpdates = false}) async {
|
||||||
|
if (!ref.mounted) return;
|
||||||
await _loadInstalled(refreshUpdates: refreshUpdates, current: _current.copyWith(loadingInstalled: true));
|
await _loadInstalled(refreshUpdates: refreshUpdates, current: _current.copyWith(loadingInstalled: true));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refreshStore() async {
|
Future<void> refreshStore() async {
|
||||||
await _refreshStore(current: _current.copyWith(loadingStore: true));
|
await _refreshStore(current: _current.copyWith(loadingStore: true), forceRefresh: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadMoreStore() async {
|
Future<void> loadMoreStore() async {
|
||||||
final current = _current;
|
final current = _current;
|
||||||
final pagination = current.storePagination;
|
final pagination = current.storePagination;
|
||||||
if (pagination == null || !pagination.hasNext || current.loadingMoreStore) return;
|
if (pagination == null || !pagination.hasNext || current.loadingMoreStore) return;
|
||||||
|
final revision = _storeRevision;
|
||||||
state = AsyncValue.data(current.copyWith(loadingMoreStore: true));
|
state = AsyncValue.data(current.copyWith(loadingMoreStore: true));
|
||||||
try {
|
try {
|
||||||
final page = await GopeedSiteApi.instance.getExtensions(
|
final page = await ref
|
||||||
page: pagination.page + 1,
|
.read(gopeedSiteApiProvider)
|
||||||
limit: pagination.limit,
|
.getExtensions(
|
||||||
sort: current.storeSort,
|
page: pagination.page + 1,
|
||||||
query: current.storeQuery,
|
limit: pagination.limit,
|
||||||
);
|
sort: current.storeSort,
|
||||||
|
query: current.storeQuery,
|
||||||
|
);
|
||||||
|
if (!ref.mounted || revision != _storeRevision) return;
|
||||||
state = AsyncValue.data(
|
state = AsyncValue.data(
|
||||||
_current.copyWith(
|
_current.copyWith(
|
||||||
storeExtensions: [..._current.storeExtensions, ...page.data],
|
// Cached pages can briefly overlap when stars/install counts change.
|
||||||
|
storeExtensions: {
|
||||||
|
for (final extension in _current.storeExtensions) extension.id: extension,
|
||||||
|
for (final extension in page.data) extension.id: extension,
|
||||||
|
}.values.toList(),
|
||||||
storePagination: page.pagination,
|
storePagination: page.pagination,
|
||||||
loadingMoreStore: false,
|
loadingMoreStore: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
if (!ref.mounted || revision != _storeRevision) return;
|
||||||
state = AsyncValue.data(_current.copyWith(loadingMoreStore: false));
|
state = AsyncValue.data(_current.copyWith(loadingMoreStore: false));
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> searchStore(String query) async {
|
Future<void> searchStore(String query) async {
|
||||||
|
if (query.trim() == _current.storeQuery) return;
|
||||||
state = AsyncValue.data(_current.copyWith(storeQuery: query.trim()));
|
state = AsyncValue.data(_current.copyWith(storeQuery: query.trim()));
|
||||||
await refreshStore();
|
await _refreshStore(current: _current);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> changeSort(StoreExtensionSort sort) async {
|
Future<void> changeSort(StoreExtensionSort sort) async {
|
||||||
|
if (sort == _current.storeSort) return;
|
||||||
state = AsyncValue.data(_current.copyWith(storeSort: sort));
|
state = AsyncValue.data(_current.copyWith(storeSort: sort));
|
||||||
await refreshStore();
|
await _refreshStore(current: _current);
|
||||||
}
|
}
|
||||||
|
|
||||||
void changeFilter(ExtensionListFilter filter) {
|
void changeFilter(ExtensionListFilter filter) {
|
||||||
@@ -185,12 +203,11 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool canUpdateItem(ExtensionListItem item) {
|
bool canUpdateItem(ExtensionListItem item) {
|
||||||
final current = _current;
|
final installed = item.installed;
|
||||||
if (item.installed == null) return false;
|
if (installed == null) return false;
|
||||||
if (current.listFilter == ExtensionListFilter.market && item.store != null) {
|
// The backend checks GitHub live for every installed extension; the store
|
||||||
return _compareVersion(item.store!.version, item.installed!.version) > 0;
|
// index may lag behind and must not decide update visibility.
|
||||||
}
|
return _current.updateFlags.containsKey(installed.identity);
|
||||||
return current.updateFlags.containsKey(item.installed!.identity);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> installFromStore(StoreExtension extension) async {
|
Future<void> installFromStore(StoreExtension extension) async {
|
||||||
@@ -240,6 +257,9 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|||||||
await _runBusy(extension.identity, () async {
|
await _runBusy(extension.identity, () async {
|
||||||
await ref.read(gopeedServiceProvider).updateExtension(extension.identity);
|
await ref.read(gopeedServiceProvider).updateExtension(extension.identity);
|
||||||
await loadInstalled(refreshUpdates: false);
|
await loadInstalled(refreshUpdates: false);
|
||||||
|
// Drop the stale flag immediately; the background re-check confirms it.
|
||||||
|
final flags = Map<String, String>.of(_current.updateFlags)..remove(extension.identity);
|
||||||
|
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
|
||||||
unawaited(checkUpdate());
|
unawaited(checkUpdate());
|
||||||
_bumpStoreInstallCount(extension.identity);
|
_bumpStoreInstallCount(extension.identity);
|
||||||
_reportInstallSafe(extension.identity);
|
_reportInstallSafe(extension.identity);
|
||||||
@@ -255,23 +275,43 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> checkUpdate() async {
|
Future<void> checkUpdate() {
|
||||||
final flags = <String, String>{};
|
if (!ref.mounted) return Future.value();
|
||||||
for (final ext in _current.installedExtensions) {
|
_checkUpdatesAgain = true;
|
||||||
try {
|
// Installation/refresh can request a fresh scan while one is running.
|
||||||
final resp = await ref.read(gopeedServiceProvider).upgradeCheckExtension(ext.identity);
|
// Coalesce those requests instead of launching overlapping Git operations.
|
||||||
if (resp.newVersion.isNotEmpty) {
|
return _updateCheck ??= _runUpdateChecks().whenComplete(() => _updateCheck = null);
|
||||||
flags[ext.identity] = resp.newVersion;
|
}
|
||||||
|
|
||||||
|
Future<void> _runUpdateChecks() async {
|
||||||
|
final service = ref.read(gopeedServiceProvider);
|
||||||
|
while (ref.mounted && _checkUpdatesAgain) {
|
||||||
|
_checkUpdatesAgain = false;
|
||||||
|
final revision = _installedRevision;
|
||||||
|
final installed = List<Extension>.of(_current.installedExtensions);
|
||||||
|
final flags = <String, String>{};
|
||||||
|
for (final ext in installed) {
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
if (revision != _installedRevision) break;
|
||||||
|
try {
|
||||||
|
final resp = await service.upgradeCheckExtension(ext.identity);
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
if (revision != _installedRevision) break;
|
||||||
|
if (resp.newVersion.isNotEmpty) flags[ext.identity] = resp.newVersion;
|
||||||
|
} catch (_) {
|
||||||
|
// A failed update check must never prevent loading the store.
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
}
|
||||||
|
if (ref.mounted && revision == _installedRevision) {
|
||||||
|
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void tryOpenDevMode() {
|
void tryOpenDevMode() {
|
||||||
if (_devModeCount == 0) {
|
if (_devModeCount == 0) {
|
||||||
Future.delayed(const Duration(seconds: 2), () {
|
Future.delayed(const Duration(seconds: 2), () {
|
||||||
if (_current.devMode) return;
|
if (!ref.mounted || _current.devMode) return;
|
||||||
_devModeCount = 0;
|
_devModeCount = 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -284,26 +324,40 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|||||||
ExtensionsState get _current => state.value ?? const ExtensionsState();
|
ExtensionsState get _current => state.value ?? const ExtensionsState();
|
||||||
|
|
||||||
Future<ExtensionsState> _loadInstalled({required bool refreshUpdates, required ExtensionsState current}) async {
|
Future<ExtensionsState> _loadInstalled({required bool refreshUpdates, required ExtensionsState current}) async {
|
||||||
|
final revision = ++_installedRevision;
|
||||||
state = AsyncValue.data(current.copyWith(loadingInstalled: true));
|
state = AsyncValue.data(current.copyWith(loadingInstalled: true));
|
||||||
final installed = await ref.read(gopeedServiceProvider).getExtensions();
|
final installed = await ref.read(gopeedServiceProvider).getExtensions();
|
||||||
|
if (!ref.mounted) return current;
|
||||||
|
if (revision != _installedRevision) return _current;
|
||||||
state = AsyncValue.data(_current.copyWith(installedExtensions: installed, loadingInstalled: false));
|
state = AsyncValue.data(_current.copyWith(installedExtensions: installed, loadingInstalled: false));
|
||||||
if (refreshUpdates) {
|
if (refreshUpdates) {
|
||||||
await checkUpdate();
|
unawaited(checkUpdate());
|
||||||
}
|
}
|
||||||
return _current;
|
return _current;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshStore({required ExtensionsState current}) async {
|
Future<void> _refreshStore({required ExtensionsState current, bool forceRefresh = false}) async {
|
||||||
state = AsyncValue.data(current.copyWith(loadingStore: true));
|
final revision = ++_storeRevision;
|
||||||
final page = await GopeedSiteApi.instance.getExtensions(
|
state = AsyncValue.data(current.copyWith(loadingStore: true, loadingMoreStore: false));
|
||||||
page: 1,
|
try {
|
||||||
limit: 20,
|
final page = await ref
|
||||||
sort: _current.storeSort,
|
.read(gopeedSiteApiProvider)
|
||||||
query: _current.storeQuery,
|
.getExtensions(
|
||||||
);
|
page: 1,
|
||||||
state = AsyncValue.data(
|
limit: 20,
|
||||||
_current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false),
|
sort: current.storeSort,
|
||||||
);
|
query: current.storeQuery,
|
||||||
|
forceRefresh: forceRefresh,
|
||||||
|
);
|
||||||
|
if (!ref.mounted || revision != _storeRevision) return;
|
||||||
|
state = AsyncValue.data(
|
||||||
|
_current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
if (!ref.mounted || revision != _storeRevision) return;
|
||||||
|
state = AsyncValue.data(_current.copyWith(loadingStore: false));
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runBusy(String id, Future<void> Function() action) async {
|
Future<void> _runBusy(String id, Future<void> Function() action) async {
|
||||||
@@ -312,12 +366,15 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
} finally {
|
} finally {
|
||||||
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
|
if (ref.mounted) {
|
||||||
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
|
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
|
||||||
|
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _bumpStoreInstallCount(String id) {
|
void _bumpStoreInstallCount(String id) {
|
||||||
|
if (!ref.mounted) return;
|
||||||
final current = _current;
|
final current = _current;
|
||||||
final index = current.storeExtensions.indexWhere((e) => e.id == id);
|
final index = current.storeExtensions.indexWhere((e) => e.id == id);
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
@@ -342,36 +399,19 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|||||||
topics: ext.topics,
|
topics: ext.topics,
|
||||||
createdAt: ext.createdAt,
|
createdAt: ext.createdAt,
|
||||||
updatedAt: ext.updatedAt,
|
updatedAt: ext.updatedAt,
|
||||||
|
hasDetails: ext.hasDetails,
|
||||||
);
|
);
|
||||||
state = AsyncValue.data(current.copyWith(storeExtensions: updated));
|
state = AsyncValue.data(current.copyWith(storeExtensions: updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reportInstallSafe(String id) {
|
void _reportInstallSafe(String id) {
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
final siteApi = ref.read(gopeedSiteApiProvider);
|
||||||
unawaited(() async {
|
unawaited(() async {
|
||||||
try {
|
try {
|
||||||
await GopeedSiteApi.instance.reportExtensionInstall(id);
|
await siteApi.reportExtensionInstall(id);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}());
|
}());
|
||||||
}
|
}
|
||||||
|
|
||||||
static int _compareVersion(String a, String b) {
|
|
||||||
final aNums = _toVersionNumbers(a);
|
|
||||||
final bNums = _toVersionNumbers(b);
|
|
||||||
final maxLen = aNums.length > bNums.length ? aNums.length : bNums.length;
|
|
||||||
for (var i = 0; i < maxLen; i++) {
|
|
||||||
final left = i < aNums.length ? aNums[i] : 0;
|
|
||||||
final right = i < bNums.length ? bNums[i] : 0;
|
|
||||||
if (left > right) return 1;
|
|
||||||
if (left < right) return -1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<int> _toVersionNumbers(String version) {
|
|
||||||
return version
|
|
||||||
.split(RegExp(r'[^0-9]+'))
|
|
||||||
.where((part) => part.isNotEmpty)
|
|
||||||
.map((part) => int.tryParse(part) ?? 0)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../api/gopeed_site_api.dart';
|
||||||
|
import '../../../api/model/store_extension.dart';
|
||||||
|
|
||||||
|
final gopeedSiteApiProvider = Provider<GopeedSiteApi>((ref) => GopeedSiteApi.instance);
|
||||||
|
|
||||||
|
typedef StoreExtensionDetailsKey = ({String id, String version});
|
||||||
|
|
||||||
|
// A shared future deduplicates drawer/page requests. Successful details survive
|
||||||
|
// closing the view, while failures can be retried and new versions get a new key.
|
||||||
|
final storeExtensionDetailsProvider = FutureProvider.autoDispose.family<StoreExtension, StoreExtensionDetailsKey>((
|
||||||
|
ref,
|
||||||
|
key,
|
||||||
|
) async {
|
||||||
|
final extension = await ref.watch(gopeedSiteApiProvider).getExtension(key.id, version: key.version);
|
||||||
|
if (ref.mounted) {
|
||||||
|
final keepAlive = ref.keepAlive();
|
||||||
|
final expiry = Timer(const Duration(minutes: 30), keepAlive.close);
|
||||||
|
ref.onDispose(expiry.cancel);
|
||||||
|
}
|
||||||
|
return extension;
|
||||||
|
});
|
||||||
@@ -8,6 +8,8 @@ import '../../../../shared/theme/app_palette.dart';
|
|||||||
import '../../../../shared/widgets/detail/app_detail_surface.dart';
|
import '../../../../shared/widgets/detail/app_detail_surface.dart';
|
||||||
import '../../application/extensions_controller.dart';
|
import '../../application/extensions_controller.dart';
|
||||||
import '../widgets/extension_detail_view.dart';
|
import '../widgets/extension_detail_view.dart';
|
||||||
|
import '../widgets/extension_update_dialog.dart';
|
||||||
|
import '../widgets/extension_update_status.dart';
|
||||||
|
|
||||||
class ExtensionDetailsPage extends ConsumerWidget {
|
class ExtensionDetailsPage extends ConsumerWidget {
|
||||||
const ExtensionDetailsPage({super.key, required this.extensionId, this.initialItem});
|
const ExtensionDetailsPage({super.key, required this.extensionId, this.initialItem});
|
||||||
@@ -24,6 +26,15 @@ class ExtensionDetailsPage extends ConsumerWidget {
|
|||||||
return AppDetailPage(
|
return AppDetailPage(
|
||||||
title: item?.title ?? context.l10n.extensions,
|
title: item?.title ?? context.l10n.extensions,
|
||||||
onBack: () => context.canPop() ? context.pop() : context.go('/extensions'),
|
onBack: () => context.canPop() ? context.pop() : context.go('/extensions'),
|
||||||
|
titleTrailing: _canUpdate(stateAsync.value, item)
|
||||||
|
? ExtensionUpdateStatus(
|
||||||
|
key: const ValueKey('extension-details-update-mobile'),
|
||||||
|
label: context.l10n.extensionCanUpdate,
|
||||||
|
onTap: item?.installed == null
|
||||||
|
? null
|
||||||
|
: () => showExtensionUpdateDialog(context, item!.installed!),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
child: item == null
|
child: item == null
|
||||||
? Center(
|
? Center(
|
||||||
child: stateAsync.isLoading
|
child: stateAsync.isLoading
|
||||||
@@ -33,4 +44,9 @@ class ExtensionDetailsPage extends ConsumerWidget {
|
|||||||
: ExtensionDetailView(key: ValueKey(item.id), item: item, mobile: true),
|
: ExtensionDetailView(key: ValueKey(item.id), item: item, mobile: true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _canUpdate(ExtensionsState? state, ExtensionListItem? item) {
|
||||||
|
final installed = item?.installed;
|
||||||
|
return item != null && installed != null && (state?.updateFlags.containsKey(installed.identity) ?? false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -28,8 +28,10 @@ import '../widgets/extension_detail_view.dart';
|
|||||||
import '../widgets/extension_icon.dart';
|
import '../widgets/extension_icon.dart';
|
||||||
import '../widgets/extension_setting_field.dart';
|
import '../widgets/extension_setting_field.dart';
|
||||||
import '../widgets/extension_update_dialog.dart';
|
import '../widgets/extension_update_dialog.dart';
|
||||||
|
import '../widgets/extension_update_status.dart';
|
||||||
|
|
||||||
const _extensionCardMinWidth = 290.0;
|
// Keep three columns in the 1024px reference window with the wider primary rail.
|
||||||
|
const _extensionCardMinWidth = 280.0;
|
||||||
const _extensionGridSpacing = 10.0;
|
const _extensionGridSpacing = 10.0;
|
||||||
|
|
||||||
int _extensionGridColumnCount(double width) {
|
int _extensionGridColumnCount(double width) {
|
||||||
@@ -485,25 +487,7 @@ class _Content extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _canUpdate(ExtensionsState state, ExtensionListItem item) {
|
bool _canUpdate(ExtensionsState state, ExtensionListItem item) {
|
||||||
if (item.installed == null) return false;
|
return item.installed != null && state.updateFlags.containsKey(item.installed!.identity);
|
||||||
if (state.listFilter == ExtensionListFilter.market && item.store != null) {
|
|
||||||
return _compareVersion(item.store!.version, item.installed!.version) > 0;
|
|
||||||
}
|
|
||||||
return state.updateFlags.containsKey(item.installed!.identity);
|
|
||||||
}
|
|
||||||
|
|
||||||
int _compareVersion(String a, String b) {
|
|
||||||
final aNums = a.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0);
|
|
||||||
final bNums = b.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0);
|
|
||||||
final left = aNums.toList();
|
|
||||||
final right = bNums.toList();
|
|
||||||
final maxLen = left.length > right.length ? left.length : right.length;
|
|
||||||
for (var i = 0; i < maxLen; i++) {
|
|
||||||
final l = i < left.length ? left[i] : 0;
|
|
||||||
final r = i < right.length ? right[i] : 0;
|
|
||||||
if (l != r) return l.compareTo(r);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -994,11 +978,28 @@ class _ExtensionCard extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
item.title,
|
children: [
|
||||||
maxLines: 1,
|
Flexible(
|
||||||
overflow: TextOverflow.ellipsis,
|
child: Text(
|
||||||
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700),
|
item.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (canUpdate && installed != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Flexible(
|
||||||
|
child: ExtensionUpdateStatus(
|
||||||
|
key: ValueKey('extension-card-update-status-${item.id}'),
|
||||||
|
label: context.l10n.extensionCanUpdate,
|
||||||
|
onTap: busy ? null : () => showExtensionUpdateDialog(context, installed),
|
||||||
|
compact: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
@@ -1067,13 +1068,6 @@ class _ExtensionCard extends ConsumerWidget {
|
|||||||
key: ValueKey('extension-card-actions-${item.id}'),
|
key: ValueKey('extension-card-actions-${item.id}'),
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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)
|
if (installed == null && item.store != null)
|
||||||
shad.GhostButton(
|
shad.GhostButton(
|
||||||
key: ValueKey('install-store-extension-${item.store!.id}'),
|
key: ValueKey('install-store-extension-${item.store!.id}'),
|
||||||
@@ -1127,21 +1121,17 @@ class _ExtensionCard extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (item.store == null) return card;
|
return GestureDetector(
|
||||||
return MouseRegion(
|
key: ValueKey('open-extension-details-${item.id}'),
|
||||||
cursor: SystemMouseCursors.click,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: GestureDetector(
|
onTap: () {
|
||||||
key: ValueKey('open-extension-details-${item.id}'),
|
if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) {
|
||||||
behavior: HitTestBehavior.opaque,
|
context.push('/extensions/${Uri.encodeComponent(item.id)}', extra: item);
|
||||||
onTap: () {
|
return;
|
||||||
if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) {
|
}
|
||||||
context.push('/extensions/${Uri.encodeComponent(item.id)}', extra: item);
|
onOpenDetails(item);
|
||||||
return;
|
},
|
||||||
}
|
child: card,
|
||||||
onOpenDetails(item);
|
|
||||||
},
|
|
||||||
child: card,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-44
@@ -19,22 +19,36 @@ import '../../../../shared/widgets/app_toast.dart';
|
|||||||
import '../../../../shared/widgets/detail/app_detail_surface.dart';
|
import '../../../../shared/widgets/detail/app_detail_surface.dart';
|
||||||
import '../../../../util/util.dart';
|
import '../../../../util/util.dart';
|
||||||
import '../../application/extensions_controller.dart';
|
import '../../application/extensions_controller.dart';
|
||||||
|
import '../../application/store_extension_details_provider.dart';
|
||||||
import 'extension_icon.dart';
|
import 'extension_icon.dart';
|
||||||
import 'extension_update_dialog.dart';
|
import 'extension_update_dialog.dart';
|
||||||
|
import 'extension_update_status.dart';
|
||||||
|
|
||||||
class ExtensionDetailDrawer extends StatelessWidget {
|
class ExtensionDetailDrawer extends ConsumerWidget {
|
||||||
const ExtensionDetailDrawer({super.key, required this.item, required this.onClose});
|
const ExtensionDetailDrawer({super.key, required this.item, required this.onClose});
|
||||||
|
|
||||||
final ExtensionListItem? item;
|
final ExtensionListItem? item;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final state = ref.watch(extensionsControllerProvider).value;
|
||||||
|
final installed = item?.installed;
|
||||||
|
final canUpdate =
|
||||||
|
item != null && installed != null && (state?.updateFlags.containsKey(installed.identity) ?? false);
|
||||||
|
final updateChip = canUpdate
|
||||||
|
? ExtensionUpdateStatus(
|
||||||
|
key: const ValueKey('extension-details-update'),
|
||||||
|
label: context.l10n.extensionCanUpdate,
|
||||||
|
onTap: () => showExtensionUpdateDialog(context, installed),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
return AppDetailDrawer(
|
return AppDetailDrawer(
|
||||||
open: item != null,
|
open: item != null,
|
||||||
title: item?.title ?? '',
|
title: item?.title ?? '',
|
||||||
onClose: onClose,
|
onClose: onClose,
|
||||||
drawerKey: const ValueKey('extension-details-drawer'),
|
drawerKey: const ValueKey('extension-details-drawer'),
|
||||||
|
titleTrailing: updateChip,
|
||||||
child: item == null ? const SizedBox.shrink() : ExtensionDetailView(item: item!),
|
child: item == null ? const SizedBox.shrink() : ExtensionDetailView(item: item!),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -50,7 +64,13 @@ class ExtensionDetailView extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final palette = AppPalette.of(context);
|
final palette = AppPalette.of(context);
|
||||||
final state = ref.watch(extensionsControllerProvider).value;
|
final state = ref.watch(extensionsControllerProvider).value;
|
||||||
final current = state?.findItem(item.id) ?? item;
|
final summary = state?.findItem(item.id) ?? item;
|
||||||
|
final summaryStore = summary.store;
|
||||||
|
final detailKey = summaryStore == null || summaryStore.hasDetails
|
||||||
|
? null
|
||||||
|
: (id: summaryStore.id, version: summaryStore.version);
|
||||||
|
final details = detailKey == null ? null : ref.watch(storeExtensionDetailsProvider(detailKey));
|
||||||
|
final current = ExtensionListItem(installed: summary.installed, store: details?.value ?? summaryStore);
|
||||||
final installed = current.installed;
|
final installed = current.installed;
|
||||||
final store = current.store;
|
final store = current.store;
|
||||||
final busy = state?.busyExtensionIds.contains(current.id) ?? false;
|
final busy = state?.busyExtensionIds.contains(current.id) ?? false;
|
||||||
@@ -103,9 +123,7 @@ class ExtensionDetailView extends ConsumerWidget {
|
|||||||
'v${current.version}',
|
'v${current.version}',
|
||||||
style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600),
|
style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
if (store != null) _ExtensionStats(store: store),
|
if (summaryStore != null) _ExtensionStats(store: summaryStore),
|
||||||
if (installed != null && canUpdate)
|
|
||||||
_ExtensionStatus(label: context.l10n.extensionCanUpdate, emphasized: true),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -130,15 +148,6 @@ class ExtensionDetailView extends ConsumerWidget {
|
|||||||
_runAction(context, () => ref.read(extensionsControllerProvider.notifier).installFromStore(store)),
|
_runAction(context, () => ref.read(extensionsControllerProvider.notifier).installFromStore(store)),
|
||||||
child: Text(context.l10n.extensionInstall),
|
child: Text(context.l10n.extensionInstall),
|
||||||
),
|
),
|
||||||
if (installed != null && canUpdate)
|
|
||||||
AppLoadingButton(
|
|
||||||
key: const ValueKey('extension-details-update'),
|
|
||||||
loading: false,
|
|
||||||
variant: AppLoadingButtonVariant.primary,
|
|
||||||
icon: const Icon(Icons.refresh, size: 16),
|
|
||||||
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
|
|
||||||
child: Text(context.l10n.newVersionUpdate),
|
|
||||||
),
|
|
||||||
if (installed != null && !canUpdate)
|
if (installed != null && !canUpdate)
|
||||||
AppLoadingButton(
|
AppLoadingButton(
|
||||||
key: const ValueKey('extension-details-installed'),
|
key: const ValueKey('extension-details-installed'),
|
||||||
@@ -162,6 +171,21 @@ class ExtensionDetailView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (details?.isLoading == true)
|
||||||
|
const Padding(
|
||||||
|
key: ValueKey('extension-details-loading'),
|
||||||
|
padding: EdgeInsets.only(top: 24),
|
||||||
|
child: Center(child: shad.CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
if (details?.hasError == true && detailKey != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 24),
|
||||||
|
child: shad.SecondaryButton(
|
||||||
|
key: const ValueKey('extension-details-retry'),
|
||||||
|
onPressed: () => ref.invalidate(storeExtensionDetailsProvider(detailKey)),
|
||||||
|
child: Text(context.l10n.retry),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (store != null) ...[SizedBox(height: mobile ? 24 : 28), _ExtensionReadme(item: current, mobile: mobile)],
|
if (store != null) ...[SizedBox(height: mobile ? 24 : 28), _ExtensionReadme(item: current, mobile: mobile)],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -204,6 +228,7 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> {
|
|||||||
final previous = oldWidget.item.installed;
|
final previous = oldWidget.item.installed;
|
||||||
final current = widget.item.installed;
|
final current = widget.item.installed;
|
||||||
if (previous?.identity != current?.identity ||
|
if (previous?.identity != current?.identity ||
|
||||||
|
oldWidget.item.id != widget.item.id ||
|
||||||
previous?.devMode != current?.devMode ||
|
previous?.devMode != current?.devMode ||
|
||||||
previous?.devPath != current?.devPath ||
|
previous?.devPath != current?.devPath ||
|
||||||
oldWidget.item.store?.readme != widget.item.store?.readme) {
|
oldWidget.item.store?.readme != widget.item.store?.readme) {
|
||||||
@@ -276,35 +301,6 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ExtensionStatus extends StatelessWidget {
|
|
||||||
const _ExtensionStatus({required this.label, required this.emphasized});
|
|
||||||
|
|
||||||
final String label;
|
|
||||||
final bool emphasized;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final palette = AppPalette.of(context);
|
|
||||||
final dotColor = emphasized ? palette.brand : palette.success;
|
|
||||||
final textColor = emphasized ? palette.textPrimary : palette.success;
|
|
||||||
return Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 5,
|
|
||||||
height: 5,
|
|
||||||
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 5),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(color: textColor, fontSize: 11.5, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LinkButtonContent extends StatelessWidget {
|
class _LinkButtonContent extends StatelessWidget {
|
||||||
const _LinkButtonContent({required this.icon, required this.label});
|
const _LinkButtonContent({required this.icon, required this.label});
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ class _ExtensionUpdateDialogState extends ConsumerState<_ExtensionUpdateDialog>
|
|||||||
canPop: !_updating,
|
canPop: !_updating,
|
||||||
child: AlertDialog(
|
child: AlertDialog(
|
||||||
key: const ValueKey('extension-update-dialog'),
|
key: const ValueKey('extension-update-dialog'),
|
||||||
title: Text(context.l10n.extensionCanUpdate),
|
title: Text(context.l10n.newVersionAvailable),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../../../../shared/theme/app_palette.dart';
|
||||||
|
|
||||||
|
/// Green status dot with an optional label, marking an available update.
|
||||||
|
/// Pass [onTap] to make it open the update prompt.
|
||||||
|
class ExtensionUpdateStatus extends StatelessWidget {
|
||||||
|
const ExtensionUpdateStatus({super.key, this.label, this.onTap, this.compact = false});
|
||||||
|
|
||||||
|
final String? label;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final palette = AppPalette.of(context);
|
||||||
|
final content = Container(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 8, vertical: compact ? 2 : 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: palette.brandSoft,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(color: palette.brand.withValues(alpha: 0.35)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(color: palette.brand, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
if (label != null) ...[
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: palette.textPrimary, fontSize: 11.5, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (onTap == null) return content;
|
||||||
|
return MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 2, vertical: compact ? 2 : 4),
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2128,7 +2128,7 @@ class _BrowserExtensionLinks extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Icons.open_in_new, size: 15),
|
const Icon(Icons.open_in_new, size: 15),
|
||||||
const SizedBox(width: AppDesignTokens.space8),
|
const SizedBox(width: AppDesignTokens.space8),
|
||||||
Text(link.label),
|
Flexible(child: Text(link.label, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+1
-1
@@ -201,7 +201,7 @@ Future<DownloadCategoryDraft?> showDownloadCategoryDialog(
|
|||||||
onPressed: () => shad.closeOverlay(dialogContext),
|
onPressed: () => shad.closeOverlay(dialogContext),
|
||||||
child: Text(dialogContext.l10n.cancel),
|
child: Text(dialogContext.l10n.cancel),
|
||||||
),
|
),
|
||||||
AppPrimaryButton(onPressed: submit, child: Text(dialogContext.l10n.confirm)),
|
AppPrimaryButton(onPressed: submit, child: Text(dialogContext.l10n.save)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ Future<String?> showTextSettingDialog(
|
|||||||
}
|
}
|
||||||
shad.closeOverlay(dialogContext, controller.text.trim());
|
shad.closeOverlay(dialogContext, controller.text.trim());
|
||||||
},
|
},
|
||||||
child: Text(dialogContext.l10n.confirm),
|
child: Text(dialogContext.l10n.save),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -321,7 +321,7 @@ Future<GithubMirrorDraft?> showGithubMirrorDialog(BuildContext context, {GithubM
|
|||||||
}
|
}
|
||||||
shad.closeOverlay(dialogContext, GithubMirrorDraft(type: selectedType, url: value));
|
shad.closeOverlay(dialogContext, GithubMirrorDraft(type: selectedType, url: value));
|
||||||
},
|
},
|
||||||
child: Text(dialogContext.l10n.confirm),
|
child: Text(dialogContext.l10n.save),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class TaskRecord {
|
|||||||
this.remaining,
|
this.remaining,
|
||||||
this.downloadDuration,
|
this.downloadDuration,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
|
this.doneAt,
|
||||||
this.progress,
|
this.progress,
|
||||||
this.error,
|
this.error,
|
||||||
this.completedLabel,
|
this.completedLabel,
|
||||||
@@ -97,6 +98,7 @@ class TaskRecord {
|
|||||||
final String? remaining;
|
final String? remaining;
|
||||||
final Duration? downloadDuration;
|
final Duration? downloadDuration;
|
||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
|
final DateTime? doneAt;
|
||||||
final double? progress;
|
final double? progress;
|
||||||
final String url;
|
final String url;
|
||||||
final String storagePath;
|
final String storagePath;
|
||||||
@@ -145,6 +147,7 @@ class TaskRecord {
|
|||||||
remainingSeconds: _remainingSeconds(task, totalBytes, downloadedBytes),
|
remainingSeconds: _remainingSeconds(task, totalBytes, downloadedBytes),
|
||||||
downloadDuration: task.progress.used > 0 ? Duration(microseconds: (task.progress.used + 999) ~/ 1000) : null,
|
downloadDuration: task.progress.used > 0 ? Duration(microseconds: (task.progress.used + 999) ~/ 1000) : null,
|
||||||
createdAt: task.createdAt,
|
createdAt: task.createdAt,
|
||||||
|
doneAt: task.doneAt,
|
||||||
waiting: task.status == api_task.Status.wait,
|
waiting: task.status == api_task.Status.wait,
|
||||||
progress: progress,
|
progress: progress,
|
||||||
url: displayUrl,
|
url: displayUrl,
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
final _httpHeadersController = AppHttpHeadersController(defaultNames: const ['User-Agent', 'Cookie', 'Referer']);
|
final _httpHeadersController = AppHttpHeadersController(defaultNames: const ['User-Agent', 'Cookie', 'Referer']);
|
||||||
final _trackersController = TextEditingController();
|
final _trackersController = TextEditingController();
|
||||||
final _archivePasswordController = TextEditingController();
|
final _archivePasswordController = TextEditingController();
|
||||||
|
final _checksumHashController = TextEditingController();
|
||||||
final _formScrollController = ScrollController();
|
final _formScrollController = ScrollController();
|
||||||
|
|
||||||
String _httpMethod = 'GET';
|
String _httpMethod = 'GET';
|
||||||
@@ -74,6 +75,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
Map<String, String>? _initialLabels;
|
Map<String, String>? _initialLabels;
|
||||||
RequestProxyMode _proxyMode = RequestProxyMode.follow;
|
RequestProxyMode _proxyMode = RequestProxyMode.follow;
|
||||||
String _proxyScheme = 'http';
|
String _proxyScheme = 'http';
|
||||||
|
String _checksumAlgorithm = 'sha256';
|
||||||
bool _skipVerifyCert = false;
|
bool _skipVerifyCert = false;
|
||||||
bool? _autoTorrent;
|
bool? _autoTorrent;
|
||||||
bool? _deleteTorrentAfterDownload;
|
bool? _deleteTorrentAfterDownload;
|
||||||
@@ -134,6 +136,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
_httpHeadersController.dispose();
|
_httpHeadersController.dispose();
|
||||||
_trackersController.dispose();
|
_trackersController.dispose();
|
||||||
_archivePasswordController.dispose();
|
_archivePasswordController.dispose();
|
||||||
|
_checksumHashController.dispose();
|
||||||
_formScrollController.dispose();
|
_formScrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -243,6 +246,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
_autoExtract = extra.autoExtract;
|
_autoExtract = extra.autoExtract;
|
||||||
_archivePasswordController.text = extra.archivePassword;
|
_archivePasswordController.text = extra.archivePassword;
|
||||||
_deleteAfterExtract = extra.deleteAfterExtract;
|
_deleteAfterExtract = extra.deleteAfterExtract;
|
||||||
|
if (extra.checksum != null) {
|
||||||
|
if (extra.checksum!.algorithm.isNotEmpty) {
|
||||||
|
_checksumAlgorithm = extra.checksum!.algorithm;
|
||||||
|
}
|
||||||
|
_checksumHashController.text = extra.checksum!.expected;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -668,6 +677,32 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
AppFormPair(
|
||||||
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
|
firstLabel: 'Checksum Algorithm',
|
||||||
|
secondLabel: 'Checksum Hash',
|
||||||
|
firstFlex: 382,
|
||||||
|
secondFlex: 618,
|
||||||
|
first: AppChoiceSegmentedControl<String>(
|
||||||
|
key: const ValueKey('create-task-checksum-algorithm'),
|
||||||
|
showIcons: false,
|
||||||
|
value: _checksumAlgorithm,
|
||||||
|
buttonKeyPrefix: 'create-task-checksum-algorithm',
|
||||||
|
alignment: WrapAlignment.start,
|
||||||
|
options: const [
|
||||||
|
AppChoiceOption(value: 'md5', label: 'MD5', icon: Icons.tag),
|
||||||
|
AppChoiceOption(value: 'sha1', label: 'SHA-1', icon: Icons.tag),
|
||||||
|
AppChoiceOption(value: 'sha256', label: 'SHA-256', icon: Icons.tag),
|
||||||
|
],
|
||||||
|
onChanged: (value) => setState(() => _checksumAlgorithm = value),
|
||||||
|
),
|
||||||
|
second: _WindowTextField(
|
||||||
|
key: const ValueKey('create-task-checksum-expected'),
|
||||||
|
controller: _checksumHashController,
|
||||||
|
hintText: 'Expected hash (optional)',
|
||||||
|
),
|
||||||
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
AppFormRow(
|
AppFormRow(
|
||||||
direction: stacked ? Axis.vertical : Axis.horizontal,
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
@@ -1024,6 +1059,13 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
|
|
||||||
api_options.Options _buildOptions({String? name, List<int> selectFiles = const []}) {
|
api_options.Options _buildOptions({String? name, List<int> selectFiles = const []}) {
|
||||||
final connections = int.tryParse(_connectionsController.text.trim()) ?? 0;
|
final connections = int.tryParse(_connectionsController.text.trim()) ?? 0;
|
||||||
|
final checksumExpected = _checksumHashController.text.trim();
|
||||||
|
final checksum = checksumExpected.isNotEmpty
|
||||||
|
? api_options.ChecksumOption(
|
||||||
|
algorithm: _checksumAlgorithm,
|
||||||
|
expected: checksumExpected,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
return api_options.Options(
|
return api_options.Options(
|
||||||
name: name ?? _renameController.text.trim(),
|
name: name ?? _renameController.text.trim(),
|
||||||
path: _directoryController.text.trim(),
|
path: _directoryController.text.trim(),
|
||||||
@@ -1036,6 +1078,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
autoExtract: _autoExtract,
|
autoExtract: _autoExtract,
|
||||||
archivePassword: _archivePasswordController.text,
|
archivePassword: _archivePasswordController.text,
|
||||||
deleteAfterExtract: _deleteAfterExtract,
|
deleteAfterExtract: _deleteAfterExtract,
|
||||||
|
checksum: checksum,
|
||||||
).toJson(),
|
).toJson(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||||
|
import 'package:flutter/widgets.dart' as flutter;
|
||||||
|
|
||||||
|
import '../../../../l10n/l10n.dart';
|
||||||
|
import '../../../../shared/theme/app_design_tokens.dart';
|
||||||
|
import '../../../../shared/widgets/app_loading_button.dart';
|
||||||
|
|
||||||
|
class ResolveTaskActions extends StatelessWidget {
|
||||||
|
const ResolveTaskActions({super.key, required this.submitting, required this.onCancel, required this.onCreate});
|
||||||
|
|
||||||
|
final bool submitting;
|
||||||
|
final VoidCallback onCancel;
|
||||||
|
final VoidCallback onCreate;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Size both actions from the wider translated label, including while the
|
||||||
|
// create button replaces its label with a loader.
|
||||||
|
return IntrinsicWidth(
|
||||||
|
child: flutter.Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
flutter.Expanded(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minWidth: AppDesignTokens.dialogActionMinWidth),
|
||||||
|
child: SecondaryButton(
|
||||||
|
key: const ValueKey('resolve-cancel-button'),
|
||||||
|
onPressed: submitting ? null : onCancel,
|
||||||
|
child: Text(context.l10n.cancel, maxLines: 1, softWrap: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppDesignTokens.space8),
|
||||||
|
flutter.Expanded(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minWidth: AppDesignTokens.dialogActionMinWidth),
|
||||||
|
child: AppLoadingButton(
|
||||||
|
key: const ValueKey('resolve-create-button'),
|
||||||
|
onPressed: onCreate,
|
||||||
|
loading: submitting,
|
||||||
|
variant: AppLoadingButtonVariant.primary,
|
||||||
|
child: Text(context.l10n.createAction, maxLines: 1, softWrap: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -319,6 +319,14 @@ class _TaskInfoTab extends StatelessWidget {
|
|||||||
_InfoBlock(label: context.l10n.remaining, value: remaining),
|
_InfoBlock(label: context.l10n.remaining, value: remaining),
|
||||||
if (task.createdAt case final createdAt?)
|
if (task.createdAt case final createdAt?)
|
||||||
_InfoBlock(label: context.l10n.createdAt, value: _formatTaskDateTime(createdAt)),
|
_InfoBlock(label: context.l10n.createdAt, value: _formatTaskDateTime(createdAt)),
|
||||||
|
if (task.status == TaskStatus.completed)
|
||||||
|
_InfoBlock(
|
||||||
|
label: context.l10n.doneAt,
|
||||||
|
value: switch (task.doneAt) {
|
||||||
|
final doneAt? => _formatTaskDateTime(doneAt),
|
||||||
|
_ => '—',
|
||||||
|
},
|
||||||
|
),
|
||||||
Container(
|
Container(
|
||||||
key: const ValueKey('task-details-info-divider'),
|
key: const ValueKey('task-details-info-divider'),
|
||||||
height: 1,
|
height: 1,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:shadcn_flutter/shadcn_flutter.dart' show kDefaultDuration;
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
class TaskProgressBar extends StatefulWidget {
|
class TaskProgressBar extends StatefulWidget {
|
||||||
@@ -74,68 +75,76 @@ class _TaskProgressBarState extends State<TaskProgressBar> with SingleTickerProv
|
|||||||
color: widget.trackColor,
|
color: widget.trackColor,
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return AnimatedBuilder(
|
final progress = (widget.value ?? 0).clamp(0.0, 1.0);
|
||||||
animation: _controller,
|
return TweenAnimationBuilder<double>(
|
||||||
builder: (context, child) {
|
tween: Tween<double>(begin: progress, end: progress),
|
||||||
if (widget.indeterminate) {
|
duration: MediaQuery.disableAnimationsOf(context) || widget.indeterminate
|
||||||
final segmentWidth = constraints.maxWidth * 0.4;
|
? Duration.zero
|
||||||
final left = lerpDouble(-segmentWidth, constraints.maxWidth, _controller.value);
|
: kDefaultDuration,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
builder: (context, animatedProgress, child) => AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
if (widget.indeterminate) {
|
||||||
|
final segmentWidth = constraints.maxWidth * 0.4;
|
||||||
|
final left = lerpDouble(-segmentWidth, constraints.maxWidth, _controller.value);
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
left: left,
|
||||||
|
width: segmentWidth,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: _ProgressFill(
|
||||||
|
color: widget.fillColor,
|
||||||
|
shimmer: widget.shimmer,
|
||||||
|
highlightStartColor: widget.highlightStartColor,
|
||||||
|
highlightEndColor: widget.highlightEndColor,
|
||||||
|
bandLeft: lerpDouble(-segmentWidth, segmentWidth * 2, _controller.value),
|
||||||
|
bandWidth: segmentWidth * 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final fillWidth = constraints.maxWidth * animatedProgress;
|
||||||
|
final bandWidth = fillWidth == 0 ? 0.0 : (fillWidth * 0.35).clamp(16.0, 40.0);
|
||||||
|
final travelDistance = fillWidth + bandWidth * 2;
|
||||||
|
final durationMs = (travelDistance / _shimmerPixelsPerSecond * 1000)
|
||||||
|
.clamp(_shimmerMinDurationMs, _shimmerMaxDurationMs)
|
||||||
|
.round();
|
||||||
|
|
||||||
|
if (widget.shimmer && widget.value != null && fillWidth > 0) {
|
||||||
|
final duration = Duration(milliseconds: durationMs);
|
||||||
|
if (_controller.duration != duration) {
|
||||||
|
_controller.duration = duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
Positioned(
|
Align(
|
||||||
left: left,
|
alignment: Alignment.centerLeft,
|
||||||
width: segmentWidth,
|
child: SizedBox(
|
||||||
top: 0,
|
width: fillWidth,
|
||||||
bottom: 0,
|
child: _ProgressFill(
|
||||||
child: _ProgressFill(
|
color: widget.fillColor,
|
||||||
color: widget.fillColor,
|
shimmer: widget.shimmer,
|
||||||
shimmer: widget.shimmer,
|
highlightStartColor: widget.highlightStartColor,
|
||||||
highlightStartColor: widget.highlightStartColor,
|
highlightEndColor: widget.highlightEndColor,
|
||||||
highlightEndColor: widget.highlightEndColor,
|
bandLeft: lerpDouble(-bandWidth, fillWidth + bandWidth, _controller.value),
|
||||||
bandLeft: lerpDouble(-segmentWidth, segmentWidth * 2, _controller.value),
|
bandWidth: bandWidth,
|
||||||
bandWidth: segmentWidth * 0.5,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
),
|
||||||
final fillWidth = constraints.maxWidth * ((widget.value ?? 0).clamp(0, 1));
|
|
||||||
final bandWidth = fillWidth == 0 ? 0.0 : (fillWidth * 0.35).clamp(16.0, 40.0);
|
|
||||||
final travelDistance = fillWidth + bandWidth * 2;
|
|
||||||
final durationMs = (travelDistance / _shimmerPixelsPerSecond * 1000)
|
|
||||||
.clamp(_shimmerMinDurationMs, _shimmerMaxDurationMs)
|
|
||||||
.round();
|
|
||||||
|
|
||||||
if (widget.shimmer && widget.value != null && fillWidth > 0) {
|
|
||||||
final duration = Duration(milliseconds: durationMs);
|
|
||||||
if (_controller.duration != duration) {
|
|
||||||
_controller.duration = duration;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: SizedBox(
|
|
||||||
width: fillWidth,
|
|
||||||
child: _ProgressFill(
|
|
||||||
color: widget.fillColor,
|
|
||||||
shimmer: widget.shimmer,
|
|
||||||
highlightStartColor: widget.highlightStartColor,
|
|
||||||
highlightEndColor: widget.highlightEndColor,
|
|
||||||
bandLeft: lerpDouble(-bandWidth, fillWidth + bandWidth, _controller.value),
|
|
||||||
bandWidth: bandWidth,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "إضافة",
|
"add": "إضافة",
|
||||||
"edit": "تعديل",
|
"edit": "تعديل",
|
||||||
"newVersionTitle": "يتوفر إصدار جديد @version",
|
"newVersionTitle": "يتوفر إصدار جديد @version",
|
||||||
"newVersionUpdate": "تحديث الآن",
|
"newVersionUpdate": "تحديث",
|
||||||
"newVersionLater": "لاحقاً",
|
"newVersionLater": "لاحقاً",
|
||||||
"extensions": "الإضافات",
|
"extensions": "الإضافات",
|
||||||
"extensionInstallUrl": "عنوان التثبيت",
|
"extensionInstallUrl": "عنوان التثبيت",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "تحديث",
|
"refresh": "تحديث",
|
||||||
"status": "الحالة",
|
"status": "الحالة",
|
||||||
"createdAt": "وقت الإنشاء",
|
"createdAt": "وقت الإنشاء",
|
||||||
|
"doneAt": "وقت الإكتمال",
|
||||||
"speed": "السرعة",
|
"speed": "السرعة",
|
||||||
"uploadSpeed": "سرعة التشغيل",
|
"uploadSpeed": "سرعة التشغيل",
|
||||||
"remaining": "البقايا",
|
"remaining": "البقايا",
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
"add": "Əlavə et",
|
"add": "Əlavə et",
|
||||||
"edit": "Redaktə et",
|
"edit": "Redaktə et",
|
||||||
"newVersionTitle": "Yeni versiya @version tapıldı",
|
"newVersionTitle": "Yeni versiya @version tapıldı",
|
||||||
"newVersionUpdate": "İndi Yenilə",
|
"newVersionUpdate": "Yenilə",
|
||||||
"newVersionLater": "Sonra",
|
"newVersionLater": "Sonra",
|
||||||
"extensions": "Uzantılar",
|
"extensions": "Uzantılar",
|
||||||
"extensionInstallUrl": "Quraşdırma URL-i",
|
"extensionInstallUrl": "Quraşdırma URL-i",
|
||||||
@@ -260,6 +260,7 @@
|
|||||||
"refresh": "Yenilə",
|
"refresh": "Yenilə",
|
||||||
"status": "Vəziyyət",
|
"status": "Vəziyyət",
|
||||||
"createdAt": "Yaradılıb",
|
"createdAt": "Yaradılıb",
|
||||||
|
"doneAt": "Tamamlanıb",
|
||||||
"speed": "Sürət",
|
"speed": "Sürət",
|
||||||
"uploadSpeed": "Yükləmə sürəti",
|
"uploadSpeed": "Yükləmə sürəti",
|
||||||
"remaining": "Qalıb",
|
"remaining": "Qalıb",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "Afegeix",
|
"add": "Afegeix",
|
||||||
"edit": "Edita",
|
"edit": "Edita",
|
||||||
"newVersionTitle": "Descobriu la nova versió @version",
|
"newVersionTitle": "Descobriu la nova versió @version",
|
||||||
"newVersionUpdate": "Actualitza ara",
|
"newVersionUpdate": "Actualitza",
|
||||||
"newVersionLater": "Més tard",
|
"newVersionLater": "Més tard",
|
||||||
"extensions": "Extensions",
|
"extensions": "Extensions",
|
||||||
"extensionInstallUrl": "URL d'instal·lació",
|
"extensionInstallUrl": "URL d'instal·lació",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "Refresca",
|
"refresh": "Refresca",
|
||||||
"status": "Estat",
|
"status": "Estat",
|
||||||
"createdAt": "Data de creació",
|
"createdAt": "Data de creació",
|
||||||
|
"doneAt": "Data de finalització",
|
||||||
"speed": "Velocitat",
|
"speed": "Velocitat",
|
||||||
"uploadSpeed": "Velocitat de pujada",
|
"uploadSpeed": "Velocitat de pujada",
|
||||||
"remaining": "Restant",
|
"remaining": "Restant",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "Hinzugefügt",
|
"add": "Hinzugefügt",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"newVersionTitle": "Entdecken Sie die neue Version @version",
|
"newVersionTitle": "Entdecken Sie die neue Version @version",
|
||||||
"newVersionUpdate": "Jetzt aktualisieren",
|
"newVersionUpdate": "Aktualisieren",
|
||||||
"newVersionLater": "Später",
|
"newVersionLater": "Später",
|
||||||
"extensions": "Erweiterungen",
|
"extensions": "Erweiterungen",
|
||||||
"extensionInstallUrl": "Installations-URL",
|
"extensionInstallUrl": "Installations-URL",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "Aktualisieren",
|
"refresh": "Aktualisieren",
|
||||||
"status": "-Status",
|
"status": "-Status",
|
||||||
"createdAt": "Erstellungszeit",
|
"createdAt": "Erstellungszeit",
|
||||||
|
"doneAt": "Abgeschlossen am",
|
||||||
"speed": "Geschwindigkeit",
|
"speed": "Geschwindigkeit",
|
||||||
"uploadSpeed": "Upload-Geschwindigkeit",
|
"uploadSpeed": "Upload-Geschwindigkeit",
|
||||||
"remaining": "Verbleibend",
|
"remaining": "Verbleibend",
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
"add": "Add",
|
"add": "Add",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"newVersionTitle": "Discover new version @version",
|
"newVersionTitle": "Discover new version @version",
|
||||||
"newVersionUpdate": "Update Now",
|
"newVersionUpdate": "Update",
|
||||||
"newVersionLater": "Later",
|
"newVersionLater": "Later",
|
||||||
"extensions": "Extensions",
|
"extensions": "Extensions",
|
||||||
"extensionInstallUrl": "Install URL",
|
"extensionInstallUrl": "Install URL",
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
"extensionLoadMore": "Load More",
|
"extensionLoadMore": "Load More",
|
||||||
"extensionNoMore": "No more extensions",
|
"extensionNoMore": "No more extensions",
|
||||||
"extensionInstalled": "Installed",
|
"extensionInstalled": "Installed",
|
||||||
"extensionCanUpdate": "Update Available",
|
"extensionCanUpdate": "Update",
|
||||||
"extensionInstall": "Install",
|
"extensionInstall": "Install",
|
||||||
"extensionLoadLocal": "Install Local Folder",
|
"extensionLoadLocal": "Install Local Folder",
|
||||||
"extensionFilterAll": "All",
|
"extensionFilterAll": "All",
|
||||||
@@ -268,6 +268,7 @@
|
|||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"createdAt": "Created",
|
"createdAt": "Created",
|
||||||
|
"doneAt": "Completed at",
|
||||||
"speed": "Speed",
|
"speed": "Speed",
|
||||||
"uploadSpeed": "Upload speed",
|
"uploadSpeed": "Upload speed",
|
||||||
"remaining": "Remaining",
|
"remaining": "Remaining",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "Añadir",
|
"add": "Añadir",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"newVersionTitle": "Nueva versión @version disponible",
|
"newVersionTitle": "Nueva versión @version disponible",
|
||||||
"newVersionUpdate": "Actualizar Ahora",
|
"newVersionUpdate": "Actualizar",
|
||||||
"newVersionLater": "Más tarde",
|
"newVersionLater": "Más tarde",
|
||||||
"extensions": "Extensiones",
|
"extensions": "Extensiones",
|
||||||
"extensionInstallUrl": "URL de instalación",
|
"extensionInstallUrl": "URL de instalación",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "Actualizar",
|
"refresh": "Actualizar",
|
||||||
"status": "Situación",
|
"status": "Situación",
|
||||||
"createdAt": "Fecha de creación",
|
"createdAt": "Fecha de creación",
|
||||||
|
"doneAt": "Fecha de finalización",
|
||||||
"speed": "Velocidad",
|
"speed": "Velocidad",
|
||||||
"uploadSpeed": "Velocidad de carga",
|
"uploadSpeed": "Velocidad de carga",
|
||||||
"remaining": "Permaneciendo",
|
"remaining": "Permaneciendo",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "افزودن",
|
"add": "افزودن",
|
||||||
"edit": "ویرایش",
|
"edit": "ویرایش",
|
||||||
"newVersionTitle": "@version عنوان: کشف نسخه جدید",
|
"newVersionTitle": "@version عنوان: کشف نسخه جدید",
|
||||||
"newVersionUpdate": "بروزرسانی",
|
"newVersionUpdate": "بهروزرسانی",
|
||||||
"newVersionLater": "بعداً",
|
"newVersionLater": "بعداً",
|
||||||
"extensions": "افزونهها",
|
"extensions": "افزونهها",
|
||||||
"extensionInstallUrl": "نشانی نصب",
|
"extensionInstallUrl": "نشانی نصب",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "تازهسازی",
|
"refresh": "تازهسازی",
|
||||||
"status": "وضعیت",
|
"status": "وضعیت",
|
||||||
"createdAt": "زمان ایجاد",
|
"createdAt": "زمان ایجاد",
|
||||||
|
"doneAt": "زمان تکمیل",
|
||||||
"speed": "سرعت",
|
"speed": "سرعت",
|
||||||
"uploadSpeed": "سرعت آپلود",
|
"uploadSpeed": "سرعت آپلود",
|
||||||
"remaining": "باقی مانده",
|
"remaining": "باقی مانده",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
"newVersionTitle": "Nouvelle version @version disponible",
|
"newVersionTitle": "Nouvelle version @version disponible",
|
||||||
"newVersionUpdate": "Mettre à jour maintenant",
|
"newVersionUpdate": "Mettre à jour",
|
||||||
"newVersionLater": "Plus tard",
|
"newVersionLater": "Plus tard",
|
||||||
"extensions": "Extensions",
|
"extensions": "Extensions",
|
||||||
"extensionInstallUrl": "URL d'installation",
|
"extensionInstallUrl": "URL d'installation",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "Rafraîchir",
|
"refresh": "Rafraîchir",
|
||||||
"status": "État",
|
"status": "État",
|
||||||
"createdAt": "Date de création",
|
"createdAt": "Date de création",
|
||||||
|
"doneAt": "Terminé le",
|
||||||
"speed": "Vitesse",
|
"speed": "Vitesse",
|
||||||
"uploadSpeed": "Vitesse de chargement",
|
"uploadSpeed": "Vitesse de chargement",
|
||||||
"remaining": "Reste",
|
"remaining": "Reste",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "Hozzáad",
|
"add": "Hozzáad",
|
||||||
"edit": "Szerk",
|
"edit": "Szerk",
|
||||||
"newVersionTitle": "Új verzió felfedezése @version",
|
"newVersionTitle": "Új verzió felfedezése @version",
|
||||||
"newVersionUpdate": "Frissítés most",
|
"newVersionUpdate": "Frissítés",
|
||||||
"newVersionLater": "Később",
|
"newVersionLater": "Később",
|
||||||
"extensions": "Bővítmények",
|
"extensions": "Bővítmények",
|
||||||
"extensionInstallUrl": "Telepítési URL",
|
"extensionInstallUrl": "Telepítési URL",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "Frissítés",
|
"refresh": "Frissítés",
|
||||||
"status": "állapot",
|
"status": "állapot",
|
||||||
"createdAt": "Létrehozás ideje",
|
"createdAt": "Létrehozás ideje",
|
||||||
|
"doneAt": "Befejezés ideje",
|
||||||
"speed": "sebesség",
|
"speed": "sebesség",
|
||||||
"uploadSpeed": "Feltöltési sebesség",
|
"uploadSpeed": "Feltöltési sebesség",
|
||||||
"remaining": "Maradt",
|
"remaining": "Maradt",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "Tambah",
|
"add": "Tambah",
|
||||||
"edit": "Sunting",
|
"edit": "Sunting",
|
||||||
"newVersionTitle": "Temukan versi batu @version",
|
"newVersionTitle": "Temukan versi batu @version",
|
||||||
"newVersionUpdate": "Perbarui Sekarang",
|
"newVersionUpdate": "Perbarui",
|
||||||
"newVersionLater": "Nanti",
|
"newVersionLater": "Nanti",
|
||||||
"extensions": "Ekstensi",
|
"extensions": "Ekstensi",
|
||||||
"extensionInstallUrl": "URL Instal",
|
"extensionInstallUrl": "URL Instal",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "Segarkan",
|
"refresh": "Segarkan",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"createdAt": "Waktu dibuat",
|
"createdAt": "Waktu dibuat",
|
||||||
|
"doneAt": "Waktu selesai",
|
||||||
"speed": "Kecepatan",
|
"speed": "Kecepatan",
|
||||||
"uploadSpeed": "Kecepatan pemutakhiran",
|
"uploadSpeed": "Kecepatan pemutakhiran",
|
||||||
"remaining": "Sisa",
|
"remaining": "Sisa",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "Aggiungi",
|
"add": "Aggiungi",
|
||||||
"edit": "Modifica",
|
"edit": "Modifica",
|
||||||
"newVersionTitle": "Scopri la nuova versione @version",
|
"newVersionTitle": "Scopri la nuova versione @version",
|
||||||
"newVersionUpdate": "Aggiorna ora",
|
"newVersionUpdate": "Aggiorna",
|
||||||
"newVersionLater": "Dopo",
|
"newVersionLater": "Dopo",
|
||||||
"extensions": "Estensioni",
|
"extensions": "Estensioni",
|
||||||
"extensionInstallUrl": "Installa URL",
|
"extensionInstallUrl": "Installa URL",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "Aggiorna",
|
"refresh": "Aggiorna",
|
||||||
"status": "Stato",
|
"status": "Stato",
|
||||||
"createdAt": "Data di creazione",
|
"createdAt": "Data di creazione",
|
||||||
|
"doneAt": "Data di completamento",
|
||||||
"speed": "Velocità",
|
"speed": "Velocità",
|
||||||
"uploadSpeed": "Velocità di carico",
|
"uploadSpeed": "Velocità di carico",
|
||||||
"remaining": "Rimanere",
|
"remaining": "Rimanere",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "追加",
|
"add": "追加",
|
||||||
"edit": "編集",
|
"edit": "編集",
|
||||||
"newVersionTitle": "新しいバージョン @version を発見する",
|
"newVersionTitle": "新しいバージョン @version を発見する",
|
||||||
"newVersionUpdate": "アップデート",
|
"newVersionUpdate": "更新",
|
||||||
"newVersionLater": "後で",
|
"newVersionLater": "後で",
|
||||||
"extensions": "拡張機能",
|
"extensions": "拡張機能",
|
||||||
"extensionInstallUrl": "インストールURL",
|
"extensionInstallUrl": "インストールURL",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "リフレッシュ",
|
"refresh": "リフレッシュ",
|
||||||
"status": "ステータス",
|
"status": "ステータス",
|
||||||
"createdAt": "作成日時",
|
"createdAt": "作成日時",
|
||||||
|
"doneAt": "完了日時",
|
||||||
"speed": "スピード",
|
"speed": "スピード",
|
||||||
"uploadSpeed": "アップロード速度",
|
"uploadSpeed": "アップロード速度",
|
||||||
"remaining": "メンテナンス",
|
"remaining": "メンテナンス",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "추가",
|
"add": "추가",
|
||||||
"edit": "편집",
|
"edit": "편집",
|
||||||
"newVersionTitle": "새 버전 @version 발견",
|
"newVersionTitle": "새 버전 @version 발견",
|
||||||
"newVersionUpdate": "지금 업데이트",
|
"newVersionUpdate": "업데이트",
|
||||||
"newVersionLater": "나중에",
|
"newVersionLater": "나중에",
|
||||||
"extensions": "확장 프로그램",
|
"extensions": "확장 프로그램",
|
||||||
"extensionInstallUrl": "설치 URL",
|
"extensionInstallUrl": "설치 URL",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "의붓기",
|
"refresh": "의붓기",
|
||||||
"status": "상태",
|
"status": "상태",
|
||||||
"createdAt": "생성 시간",
|
"createdAt": "생성 시간",
|
||||||
|
"doneAt": "완료 시간",
|
||||||
"speed": "속도",
|
"speed": "속도",
|
||||||
"uploadSpeed": "연결 속도",
|
"uploadSpeed": "연결 속도",
|
||||||
"remaining": "의논하기",
|
"remaining": "의논하기",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "Dodaj",
|
"add": "Dodaj",
|
||||||
"edit": "Edycja",
|
"edit": "Edycja",
|
||||||
"newVersionTitle": "Sprawdź aktualizację @version",
|
"newVersionTitle": "Sprawdź aktualizację @version",
|
||||||
"newVersionUpdate": "Aktualizuj teraz",
|
"newVersionUpdate": "Aktualizuj",
|
||||||
"newVersionLater": "Później",
|
"newVersionLater": "Później",
|
||||||
"extensions": "Wtyczki",
|
"extensions": "Wtyczki",
|
||||||
"extensionInstallUrl": "Instaluj z url",
|
"extensionInstallUrl": "Instaluj z url",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "Odśwież",
|
"refresh": "Odśwież",
|
||||||
"status": "Stan",
|
"status": "Stan",
|
||||||
"createdAt": "Czas utworzenia",
|
"createdAt": "Czas utworzenia",
|
||||||
|
"doneAt": "Czas ukończenia",
|
||||||
"speed": "Prędkość",
|
"speed": "Prędkość",
|
||||||
"uploadSpeed": "Prędkość wysyłania",
|
"uploadSpeed": "Prędkość wysyłania",
|
||||||
"remaining": "Pozostały",
|
"remaining": "Pozostały",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "Adicionar",
|
"add": "Adicionar",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"newVersionTitle": "Nova Versão Disponível @version",
|
"newVersionTitle": "Nova Versão Disponível @version",
|
||||||
"newVersionUpdate": "Atualizar Agora",
|
"newVersionUpdate": "Atualizar",
|
||||||
"newVersionLater": "Mais Tarde",
|
"newVersionLater": "Mais Tarde",
|
||||||
"extensions": "Extensões",
|
"extensions": "Extensões",
|
||||||
"extensionInstallUrl": "Link de Instalação",
|
"extensionInstallUrl": "Link de Instalação",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "Actualizar",
|
"refresh": "Actualizar",
|
||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
"createdAt": "Data de criação",
|
"createdAt": "Data de criação",
|
||||||
|
"doneAt": "Data de conclusão",
|
||||||
"speed": "Velocidade",
|
"speed": "Velocidade",
|
||||||
"uploadSpeed": "Velocidade de envio",
|
"uploadSpeed": "Velocidade de envio",
|
||||||
"remaining": "Restante",
|
"remaining": "Restante",
|
||||||
|
|||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "освежиться",
|
"refresh": "освежиться",
|
||||||
"status": "Статус",
|
"status": "Статус",
|
||||||
"createdAt": "Время создания",
|
"createdAt": "Время создания",
|
||||||
|
"doneAt": "Время завершения",
|
||||||
"speed": "Скорость",
|
"speed": "Скорость",
|
||||||
"uploadSpeed": "Скорость загрузки",
|
"uploadSpeed": "Скорость загрузки",
|
||||||
"remaining": "оставаться",
|
"remaining": "оставаться",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "சேர்",
|
"add": "சேர்",
|
||||||
"edit": "திருத்துக",
|
"edit": "திருத்துக",
|
||||||
"newVersionTitle": "புதிய பதிப்பைக் கண்டறியவும் @version",
|
"newVersionTitle": "புதிய பதிப்பைக் கண்டறியவும் @version",
|
||||||
"newVersionUpdate": "இப்பொழுது புதுப்பி",
|
"newVersionUpdate": "புதுப்பிப்பு",
|
||||||
"newVersionLater": "பின்னர்",
|
"newVersionLater": "பின்னர்",
|
||||||
"extensions": "நீட்டிப்புகள்",
|
"extensions": "நீட்டிப்புகள்",
|
||||||
"extensionInstallUrl": "நிறுவப்படும் URL",
|
"extensionInstallUrl": "நிறுவப்படும் URL",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "புதுப்பி",
|
"refresh": "புதுப்பி",
|
||||||
"status": "நிலை",
|
"status": "நிலை",
|
||||||
"createdAt": "உருவாக்கிய நேரம்",
|
"createdAt": "உருவாக்கிய நேரம்",
|
||||||
|
"doneAt": "முடிந்த நேரம்",
|
||||||
"speed": "வேகம்",
|
"speed": "வேகம்",
|
||||||
"uploadSpeed": "வேகம்",
|
"uploadSpeed": "வேகம்",
|
||||||
"remaining": "மீதம்",
|
"remaining": "மீதம்",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "Ekle",
|
"add": "Ekle",
|
||||||
"edit": "Düzenle",
|
"edit": "Düzenle",
|
||||||
"newVersionTitle": "Yeni sürüm @version keşfedildi",
|
"newVersionTitle": "Yeni sürüm @version keşfedildi",
|
||||||
"newVersionUpdate": "Şimdi Güncelle",
|
"newVersionUpdate": "Güncelle",
|
||||||
"newVersionLater": "Daha Sonra",
|
"newVersionLater": "Daha Sonra",
|
||||||
"extensions": "Eklentiler",
|
"extensions": "Eklentiler",
|
||||||
"extensionInstallUrl": "Kurulum URL'si",
|
"extensionInstallUrl": "Kurulum URL'si",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "Yenile",
|
"refresh": "Yenile",
|
||||||
"status": "Durum durumu",
|
"status": "Durum durumu",
|
||||||
"createdAt": "Oluşturulma zamanı",
|
"createdAt": "Oluşturulma zamanı",
|
||||||
|
"doneAt": "Tamamlanma zamanı",
|
||||||
"speed": "Hız",
|
"speed": "Hız",
|
||||||
"uploadSpeed": "Yük hızı",
|
"uploadSpeed": "Yük hızı",
|
||||||
"remaining": "Kalanlar",
|
"remaining": "Kalanlar",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "Додати",
|
"add": "Додати",
|
||||||
"edit": "Редагування",
|
"edit": "Редагування",
|
||||||
"newVersionTitle": "Доступна нова версія @Version",
|
"newVersionTitle": "Доступна нова версія @Version",
|
||||||
"newVersionUpdate": "Оновити зараз",
|
"newVersionUpdate": "Оновити",
|
||||||
"newVersionLater": "Пізніше",
|
"newVersionLater": "Пізніше",
|
||||||
"extensions": "Розширення",
|
"extensions": "Розширення",
|
||||||
"extensionInstallUrl": "URL для встановлення",
|
"extensionInstallUrl": "URL для встановлення",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "Реверс",
|
"refresh": "Реверс",
|
||||||
"status": "Контакты",
|
"status": "Контакты",
|
||||||
"createdAt": "Час створення",
|
"createdAt": "Час створення",
|
||||||
|
"doneAt": "Час завершення",
|
||||||
"speed": "ОДЯГ",
|
"speed": "ОДЯГ",
|
||||||
"uploadSpeed": "Швидкість завантаження",
|
"uploadSpeed": "Швидкість завантаження",
|
||||||
"remaining": "Ременування",
|
"remaining": "Ременування",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "Thêm",
|
"add": "Thêm",
|
||||||
"edit": "Sửa",
|
"edit": "Sửa",
|
||||||
"newVersionTitle": "Khám phá phiên bản mới @version",
|
"newVersionTitle": "Khám phá phiên bản mới @version",
|
||||||
"newVersionUpdate": "Cập nhật ngay",
|
"newVersionUpdate": "Cập nhật",
|
||||||
"newVersionLater": "Sau",
|
"newVersionLater": "Sau",
|
||||||
"extensions": "Tiện ích mở rộng",
|
"extensions": "Tiện ích mở rộng",
|
||||||
"extensionInstallUrl": "Liên kết cài đặt",
|
"extensionInstallUrl": "Liên kết cài đặt",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "Cập nhật",
|
"refresh": "Cập nhật",
|
||||||
"status": "Trạng thái",
|
"status": "Trạng thái",
|
||||||
"createdAt": "Thời gian tạo",
|
"createdAt": "Thời gian tạo",
|
||||||
|
"doneAt": "Thời gian hoàn thành",
|
||||||
"speed": "Tốc độ",
|
"speed": "Tốc độ",
|
||||||
"uploadSpeed": "Tốc độ",
|
"uploadSpeed": "Tốc độ",
|
||||||
"remaining": "Ở lại",
|
"remaining": "Ở lại",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "添加",
|
"add": "添加",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"newVersionTitle": "发现新版本 @version",
|
"newVersionTitle": "发现新版本 @version",
|
||||||
"newVersionUpdate": "立即更新",
|
"newVersionUpdate": "更新",
|
||||||
"newVersionLater": "稍后再说",
|
"newVersionLater": "稍后再说",
|
||||||
"extensions": "扩展",
|
"extensions": "扩展",
|
||||||
"extensionInstallUrl": "安装链接",
|
"extensionInstallUrl": "安装链接",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"createdAt": "创建时间",
|
"createdAt": "创建时间",
|
||||||
|
"doneAt": "完成时间",
|
||||||
"speed": "速度",
|
"speed": "速度",
|
||||||
"uploadSpeed": "上传速度",
|
"uploadSpeed": "上传速度",
|
||||||
"remaining": "剩余时间",
|
"remaining": "剩余时间",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"add": "新增",
|
"add": "新增",
|
||||||
"edit": "編輯",
|
"edit": "編輯",
|
||||||
"newVersionTitle": "發現新版本 @version",
|
"newVersionTitle": "發現新版本 @version",
|
||||||
"newVersionUpdate": "立即更新",
|
"newVersionUpdate": "更新",
|
||||||
"newVersionLater": "稍後",
|
"newVersionLater": "稍後",
|
||||||
"extensions": "擴充功能",
|
"extensions": "擴充功能",
|
||||||
"extensionInstallUrl": "安裝 URL",
|
"extensionInstallUrl": "安裝 URL",
|
||||||
@@ -246,6 +246,7 @@
|
|||||||
"refresh": "重新整理",
|
"refresh": "重新整理",
|
||||||
"status": "狀態",
|
"status": "狀態",
|
||||||
"createdAt": "建立時間",
|
"createdAt": "建立時間",
|
||||||
|
"doneAt": "完成時間",
|
||||||
"speed": "速度",
|
"speed": "速度",
|
||||||
"uploadSpeed": "上傳速度",
|
"uploadSpeed": "上傳速度",
|
||||||
"remaining": "剩餘時間",
|
"remaining": "剩餘時間",
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ class AppDesignTokens {
|
|||||||
|
|
||||||
static const double windowHeaderHeight = 30;
|
static const double windowHeaderHeight = 30;
|
||||||
static const double windowRadius = 12;
|
static const double windowRadius = 12;
|
||||||
static const double railWidth = 68;
|
// Leave room for native macOS traffic lights while keeping rail widths consistent across platforms.
|
||||||
|
static const double railWidth = 78;
|
||||||
static const double filterSidebarWidth = 192;
|
static const double filterSidebarWidth = 192;
|
||||||
static const double contentHeaderHeight = 48;
|
static const double contentHeaderHeight = 48;
|
||||||
static const double taskRowHeight = 72;
|
static const double taskRowHeight = 72;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class AppDetailDrawer extends StatelessWidget {
|
|||||||
required this.child,
|
required this.child,
|
||||||
this.drawerKey,
|
this.drawerKey,
|
||||||
this.width,
|
this.width,
|
||||||
|
this.titleTrailing,
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool open;
|
final bool open;
|
||||||
@@ -24,6 +25,7 @@ class AppDetailDrawer extends StatelessWidget {
|
|||||||
final Widget child;
|
final Widget child;
|
||||||
final Key? drawerKey;
|
final Key? drawerKey;
|
||||||
final double? width;
|
final double? width;
|
||||||
|
final Widget? titleTrailing;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -82,15 +84,25 @@ class AppDetailDrawer extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Row(
|
||||||
title,
|
children: [
|
||||||
maxLines: 1,
|
Flexible(
|
||||||
overflow: TextOverflow.ellipsis,
|
child: Text(
|
||||||
style: TextStyle(
|
title,
|
||||||
color: palette.textPrimary,
|
maxLines: 1,
|
||||||
fontSize: 16,
|
overflow: TextOverflow.ellipsis,
|
||||||
fontWeight: FontWeight.w700,
|
style: TextStyle(
|
||||||
),
|
color: palette.textPrimary,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (titleTrailing != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(child: titleTrailing!),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ class AppDetailPage extends StatelessWidget {
|
|||||||
required this.onBack,
|
required this.onBack,
|
||||||
required this.child,
|
required this.child,
|
||||||
this.backgroundColor,
|
this.backgroundColor,
|
||||||
|
this.titleTrailing,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final VoidCallback onBack;
|
final VoidCallback onBack;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final Color? backgroundColor;
|
final Color? backgroundColor;
|
||||||
|
final Widget? titleTrailing;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -53,12 +55,22 @@ class AppDetailPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Row(
|
||||||
title,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
textAlign: TextAlign.center,
|
children: [
|
||||||
maxLines: 1,
|
Flexible(
|
||||||
overflow: TextOverflow.ellipsis,
|
child: Text(
|
||||||
style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700),
|
title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (titleTrailing != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(child: titleTrailing!),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 44),
|
const SizedBox(width: 44),
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- device_info_plus (0.0.1):
|
- device_info_plus (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- file_picker (0.0.1):
|
- file_picker_darwin (1.0.0):
|
||||||
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- flutter_inappwebview_macos (0.0.1):
|
- flutter_inappwebview_macos (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@@ -41,7 +42,7 @@ DEPENDENCIES:
|
|||||||
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
|
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
|
||||||
- desktop_multi_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos`)
|
- desktop_multi_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos`)
|
||||||
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
|
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
|
||||||
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
|
- file_picker_darwin (from `Flutter/ephemeral/.symlinks/plugins/file_picker_darwin/darwin`)
|
||||||
- flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`)
|
- flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`)
|
||||||
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
@@ -68,8 +69,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
|
||||||
file_picker:
|
file_picker_darwin:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/file_picker_darwin/darwin
|
||||||
flutter_inappwebview_macos:
|
flutter_inappwebview_macos:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos
|
||||||
flutter_local_notifications:
|
flutter_local_notifications:
|
||||||
@@ -96,25 +97,25 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f
|
app_links: c3185399a5cabc2e610ee5ad52fb7269b84ff869
|
||||||
desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43
|
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
|
||||||
desktop_multi_window: 93667594ccc4b88d91a97972fd3b1b89667fa80a
|
desktop_multi_window: 566489c048b501134f9d7fb6a2354c60a9126486
|
||||||
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
|
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
|
||||||
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
|
file_picker_darwin: 27e043831d68462d54f1c40e724b1f53c9d1e8be
|
||||||
flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d
|
flutter_inappwebview_macos: bdf207b8f4ebd58e86ae06cd96b147de99a67c9b
|
||||||
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
|
flutter_local_notifications: 14e285ca39907db50704f7f46c9ab7a526bd7ead
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
open_dir_macos: 79810d7921d777f8732bf6358b3a0839a8fad9b5
|
open_dir_macos: 9649018f88e71eb9fb8aa3d95caec30a73c08c5b
|
||||||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
|
||||||
screen_retriever_macos: c5508cc3c66ff0d4db650480cf0ab691e220d933
|
screen_retriever_macos: 038d3f7b34a779b06f536842003e41e8ec1fb4b7
|
||||||
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
|
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
|
||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||||
tray_manager: a104b5c81b578d83f3c3d0f40a997c8b10810166
|
tray_manager: c2a678ee19e2a094f0c2d03660ee2c3e81cea8ab
|
||||||
uri_content: 5f98ff12ded3f2e1681b689828d747bafb03ba5f
|
uri_content: e002074bcf6fbf905a3909603f9a2b1b490fce67
|
||||||
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
url_launcher_macos: 175a54c831f4375a6cf895875f716ee5af3888ce
|
||||||
window_manager: b729e31d38fb04905235df9ea896128991cad99e
|
window_manager: e25faf20d88283a0d46e7b1a759d07261ca27575
|
||||||
|
|
||||||
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
||||||
|
|
||||||
COCOAPODS: 1.17.0
|
COCOAPODS: 1.15.2
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gopeed/api/api.dart' as api;
|
||||||
|
import 'package:gopeed/api/gopeed_site_api.dart';
|
||||||
|
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('summary requests are shared and cached; explicit refresh requests fresh data', () async {
|
||||||
|
final pending = Completer<Response<String>>();
|
||||||
|
final transport = _Transport()..onProxy = (_) => pending.future;
|
||||||
|
api.setTransportForTesting(transport);
|
||||||
|
final site = GopeedSiteApi.instance;
|
||||||
|
final first = site.getExtensions(query: 'cache-test', forceRefresh: true);
|
||||||
|
final second = site.getExtensions(query: 'cache-test');
|
||||||
|
expect(transport.urls.length, 1);
|
||||||
|
expect(Uri.parse(transport.urls.single).queryParameters['view'], 'summary');
|
||||||
|
pending.complete(
|
||||||
|
_response({
|
||||||
|
'data': [
|
||||||
|
{'id': 'test'},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect((await first).data.single.hasDetails, isFalse);
|
||||||
|
await second;
|
||||||
|
await site.getExtensions(query: 'cache-test');
|
||||||
|
expect(transport.urls.length, 1);
|
||||||
|
await site.getExtensions(query: 'cache-test', forceRefresh: true);
|
||||||
|
expect(transport.urls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed lists are not cached', () async {
|
||||||
|
final transport = _Transport()..onProxy = (_) => Future.error(StateError('offline'));
|
||||||
|
api.setTransportForTesting(transport);
|
||||||
|
final site = GopeedSiteApi.instance;
|
||||||
|
await expectLater(site.getExtensions(query: 'error-test'), throwsStateError);
|
||||||
|
transport.onProxy = (_) async => _response({
|
||||||
|
'data': [
|
||||||
|
{'id': 'recovered'},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect((await site.getExtensions(query: 'error-test')).data.single.id, 'recovered');
|
||||||
|
expect(transport.urls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detail IDs are encoded once and a null README is still loaded detail', () async {
|
||||||
|
final transport = _Transport()..onProxy = (_) async => _response({'id': '开发者@name', 'readme': null});
|
||||||
|
api.setTransportForTesting(transport);
|
||||||
|
final result = await GopeedSiteApi.instance.getExtension('开发者@name', version: '1.2.3');
|
||||||
|
final uri = Uri.parse(transport.urls.single);
|
||||||
|
expect(uri.pathSegments, ['api', 'extensions', '开发者@name']);
|
||||||
|
expect(uri.queryParameters['version'], '1.2.3');
|
||||||
|
expect(result.hasDetails, isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Response<String> _response(Map<String, dynamic> json) => Response(
|
||||||
|
requestOptions: RequestOptions(path: '/test'),
|
||||||
|
data: jsonEncode(json),
|
||||||
|
statusCode: 200,
|
||||||
|
);
|
||||||
|
|
||||||
|
class _Transport implements GopeedTransport {
|
||||||
|
final urls = <String>[];
|
||||||
|
Future<Response<String>> Function(String) onProxy = (_) async => _response({'data': []});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
|
||||||
|
urls.add(uri);
|
||||||
|
return onProxy(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
@@ -9,9 +9,9 @@ void main() {
|
|||||||
test('create-task child window has a localized native title', () {
|
test('create-task child window has a localized native title', () {
|
||||||
expect(
|
expect(
|
||||||
AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('en')),
|
AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('en')),
|
||||||
'Gopeed-Create Task',
|
'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', () {
|
test('create-task payload only carries initial task data', () {
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gopeed/api/gopeed_site_api.dart';
|
||||||
|
import 'package:gopeed/api/model/extension.dart';
|
||||||
|
import 'package:gopeed/api/model/store_extension.dart';
|
||||||
|
import 'package:gopeed/api/model/update_check_extension_resp.dart';
|
||||||
|
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||||
|
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||||
|
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||||
|
import 'package:gopeed/features/extensions/application/extensions_controller.dart';
|
||||||
|
import 'package:gopeed/features/extensions/application/store_extension_details_provider.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
test('store loads while installed data and update checks are still pending', () async {
|
||||||
|
final installed = Completer<List<Extension>>();
|
||||||
|
final update = Completer<UpdateCheckExtensionResp>();
|
||||||
|
var updateCalls = 0;
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getExtensions, (_) => installed.future)
|
||||||
|
..bind(GopeedMethods.checkExtensionUpdate, (_) {
|
||||||
|
updateCalls++;
|
||||||
|
return update.future;
|
||||||
|
});
|
||||||
|
final site = _SiteApi();
|
||||||
|
final container = _container(registry, site);
|
||||||
|
final subscription = container.listen(extensionsControllerProvider, (_, _) {});
|
||||||
|
addTearDown(subscription.close);
|
||||||
|
|
||||||
|
await _settle();
|
||||||
|
expect(site.listCalls, 1);
|
||||||
|
expect(container.read(extensionsControllerProvider).value?.storeExtensions.single.id, 'market');
|
||||||
|
expect(container.read(extensionsControllerProvider).value?.loadingStore, isFalse);
|
||||||
|
expect(updateCalls, 0);
|
||||||
|
|
||||||
|
installed.complete([_installed()]);
|
||||||
|
await _settle();
|
||||||
|
expect(updateCalls, 1);
|
||||||
|
expect(container.read(extensionsControllerProvider).value?.loadingInstalled, isFalse);
|
||||||
|
expect(container.read(extensionsControllerProvider).value?.storeExtensions.single.id, 'market');
|
||||||
|
update.complete(UpdateCheckExtensionResp(newVersion: '2.0.0'));
|
||||||
|
await _settle();
|
||||||
|
expect(container.read(extensionsControllerProvider).value?.updateFlags, {'gopeed@test': '2.0.0'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale update scan cannot restore flags after installed data changes', () async {
|
||||||
|
var installed = [_installed()];
|
||||||
|
final update = Completer<UpdateCheckExtensionResp>();
|
||||||
|
var updateCalls = 0;
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getExtensions, (_) => installed)
|
||||||
|
..bind(GopeedMethods.checkExtensionUpdate, (_) {
|
||||||
|
updateCalls++;
|
||||||
|
return update.future;
|
||||||
|
});
|
||||||
|
final container = _container(registry, _SiteApi());
|
||||||
|
final controller = container.read(extensionsControllerProvider.notifier);
|
||||||
|
await _settle();
|
||||||
|
final running = controller.checkUpdate();
|
||||||
|
expect(updateCalls, 1);
|
||||||
|
installed = [];
|
||||||
|
await controller.loadInstalled();
|
||||||
|
update.complete(UpdateCheckExtensionResp(newVersion: '2.0.0'));
|
||||||
|
await running;
|
||||||
|
expect(container.read(extensionsControllerProvider).value?.installedExtensions, isEmpty);
|
||||||
|
expect(container.read(extensionsControllerProvider).value?.updateFlags, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an update scan can finish after the controller is disposed', () async {
|
||||||
|
final update = Completer<UpdateCheckExtensionResp>();
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getExtensions, (_) => [_installed()])
|
||||||
|
..bind(GopeedMethods.checkExtensionUpdate, (_) => update.future);
|
||||||
|
final container = _container(registry, _SiteApi());
|
||||||
|
final controller = container.read(extensionsControllerProvider.notifier);
|
||||||
|
await _settle();
|
||||||
|
final running = controller.checkUpdate();
|
||||||
|
container.dispose();
|
||||||
|
update.complete(UpdateCheckExtensionResp(newVersion: '2.0.0'));
|
||||||
|
await running;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('old search results cannot replace a newer search', () async {
|
||||||
|
final old = Completer<StoreExtensionPage>();
|
||||||
|
final latest = Completer<StoreExtensionPage>();
|
||||||
|
final site = _SiteApi()
|
||||||
|
..onList = (query) => switch (query) {
|
||||||
|
'old' => old.future,
|
||||||
|
'new' => latest.future,
|
||||||
|
_ => Future.value(_page('market')),
|
||||||
|
};
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getExtensions, (_) => <Extension>[]);
|
||||||
|
final container = _container(registry, site);
|
||||||
|
final controller = container.read(extensionsControllerProvider.notifier);
|
||||||
|
await _settle();
|
||||||
|
final first = controller.searchStore('old');
|
||||||
|
final second = controller.searchStore('new');
|
||||||
|
latest.complete(_page('new-result'));
|
||||||
|
await second;
|
||||||
|
old.complete(_page('old-result'));
|
||||||
|
await first;
|
||||||
|
final state = container.read(extensionsControllerProvider).value!;
|
||||||
|
expect(state.storeQuery, 'new');
|
||||||
|
expect(state.storeExtensions.single.id, 'new-result');
|
||||||
|
expect(state.loadingStore, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlapping cached pages do not duplicate an extension', () async {
|
||||||
|
final site = _SiteApi();
|
||||||
|
site.onList = (_) async => StoreExtensionPage.fromJson({
|
||||||
|
'data': [
|
||||||
|
{'id': 'market', 'version': '1.0.0'},
|
||||||
|
if (site.listCalls > 1) {'id': 'second', 'version': '1.0.0'},
|
||||||
|
],
|
||||||
|
'pagination': {'page': site.listCalls, 'limit': 20, 'hasNext': site.listCalls == 1},
|
||||||
|
});
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getExtensions, (_) => <Extension>[]);
|
||||||
|
final container = _container(registry, site);
|
||||||
|
final controller = container.read(extensionsControllerProvider.notifier);
|
||||||
|
await _settle();
|
||||||
|
await controller.loadMoreStore();
|
||||||
|
expect(container.read(extensionsControllerProvider).value?.storeExtensions.map((e) => e.id), ['market', 'second']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detail requests are shared, cache empty READMEs, and distinguish versions', () async {
|
||||||
|
final detail = Completer<StoreExtension>();
|
||||||
|
final site = _SiteApi()..onDetail = (_, _) => detail.future;
|
||||||
|
final container = ProviderContainer(overrides: [gopeedSiteApiProvider.overrideWithValue(site)]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
const key = (id: 'market', version: '1.0.0');
|
||||||
|
final provider = storeExtensionDetailsProvider(key);
|
||||||
|
final subscription = container.listen(provider, (_, _) {});
|
||||||
|
final first = container.read(provider.future);
|
||||||
|
final second = container.read(provider.future);
|
||||||
|
expect(site.detailCalls, 1);
|
||||||
|
detail.complete(StoreExtension.fromJson({'id': 'market', 'version': '1.0.0', 'readme': null}));
|
||||||
|
expect((await first).hasDetails, isTrue);
|
||||||
|
await second;
|
||||||
|
subscription.close();
|
||||||
|
await _settle();
|
||||||
|
await container.read(provider.future);
|
||||||
|
expect(site.detailCalls, 1);
|
||||||
|
await container.read(storeExtensionDetailsProvider((id: 'market', version: '2.0.0')).future);
|
||||||
|
expect(site.detailCalls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed details can be retried', () async {
|
||||||
|
final site = _SiteApi()..onDetail = (_, _) => Future.error(StateError('offline'));
|
||||||
|
final container = ProviderContainer(overrides: [gopeedSiteApiProvider.overrideWithValue(site)]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
final provider = storeExtensionDetailsProvider((id: 'market', version: '1.0.0'));
|
||||||
|
final subscription = container.listen(provider, (_, _) {});
|
||||||
|
addTearDown(subscription.close);
|
||||||
|
await expectLater(container.read(provider.future), throwsStateError);
|
||||||
|
site.onDetail = (_, _) async => StoreExtension.fromJson({'id': 'market', 'readme': 'loaded'});
|
||||||
|
container.invalidate(provider);
|
||||||
|
expect((await container.read(provider.future)).readme, 'loaded');
|
||||||
|
expect(site.detailCalls, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ProviderContainer _container(CapabilityRegistry registry, _SiteApi site) {
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))),
|
||||||
|
gopeedSiteApiProvider.overrideWithValue(site),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _settle() async {
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreExtensionPage _page(String id) => StoreExtensionPage.fromJson({
|
||||||
|
'data': [
|
||||||
|
{'id': id, 'version': '1.0.0'},
|
||||||
|
],
|
||||||
|
'pagination': {'page': 1, 'limit': 20, 'total': 1, 'totalPages': 1},
|
||||||
|
});
|
||||||
|
|
||||||
|
Extension _installed() => Extension(
|
||||||
|
identity: 'gopeed@test',
|
||||||
|
name: 'test',
|
||||||
|
author: 'gopeed',
|
||||||
|
title: 'Test',
|
||||||
|
description: '',
|
||||||
|
icon: '',
|
||||||
|
version: '1.0.0',
|
||||||
|
homepage: '',
|
||||||
|
repository: null,
|
||||||
|
disabled: false,
|
||||||
|
devMode: false,
|
||||||
|
devPath: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
class _SiteApi implements GopeedSiteApi {
|
||||||
|
var listCalls = 0;
|
||||||
|
var detailCalls = 0;
|
||||||
|
Future<StoreExtensionPage> Function(String?) onList = (_) async => _page('market');
|
||||||
|
Future<StoreExtension> Function(String, String?) onDetail = (id, _) async =>
|
||||||
|
StoreExtension.fromJson({'id': id, 'readme': null});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<StoreExtensionPage> getExtensions({
|
||||||
|
int page = 1,
|
||||||
|
int limit = 20,
|
||||||
|
StoreExtensionSort sort = StoreExtensionSort.stars,
|
||||||
|
StoreSortOrder order = StoreSortOrder.desc,
|
||||||
|
String? query,
|
||||||
|
bool forceRefresh = false,
|
||||||
|
}) {
|
||||||
|
listCalls++;
|
||||||
|
return onList(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<StoreExtension> getExtension(String id, {String? version}) {
|
||||||
|
detailCalls++;
|
||||||
|
return onDetail(id, version);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gopeed/api/gopeed_site_api.dart';
|
||||||
|
import 'package:gopeed/api/model/store_extension.dart';
|
||||||
|
import 'package:gopeed/features/extensions/application/extensions_controller.dart';
|
||||||
|
import 'package:gopeed/features/extensions/application/store_extension_details_provider.dart';
|
||||||
|
import 'package:gopeed/features/extensions/presentation/widgets/extension_detail_view.dart';
|
||||||
|
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||||
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final width in [390.0, 1024.0]) {
|
||||||
|
testWidgets('summary details load and can retry at width $width', (tester) async {
|
||||||
|
tester.view.physicalSize = Size(width, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
final pending = Completer<StoreExtension>();
|
||||||
|
final site = _DetailApi()..request = pending.future;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
extensionsControllerProvider.overrideWith(_SummaryController.new),
|
||||||
|
gopeedSiteApiProvider.overrideWithValue(site),
|
||||||
|
],
|
||||||
|
child: shad.ShadcnApp(
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
materialTheme: AppTheme.materialLight(),
|
||||||
|
home: shad.Scaffold(
|
||||||
|
child: ExtensionDetailView(
|
||||||
|
item: ExtensionListItem(store: _summary()),
|
||||||
|
mobile: width < 600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const ValueKey('extension-details-loading')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('extension-details-install')), findsOneWidget);
|
||||||
|
pending.completeError(StateError('offline'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const ValueKey('extension-details-retry')), findsOneWidget);
|
||||||
|
site.request = Future.value(
|
||||||
|
StoreExtension.fromJson({
|
||||||
|
'id': 'market',
|
||||||
|
'title': 'Market',
|
||||||
|
'version': '1.0.0',
|
||||||
|
'readme': '# Loaded details',
|
||||||
|
'homepage': 'https://example.com',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('extension-details-retry')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Loaded details'), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('extension-details-homepage')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('extension-details-retry')), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreExtension _summary() => StoreExtension.fromJson({
|
||||||
|
'id': 'market',
|
||||||
|
'title': 'Market',
|
||||||
|
'version': '1.0.0',
|
||||||
|
'repoUrl': 'https://github.com/test/repo',
|
||||||
|
});
|
||||||
|
|
||||||
|
class _SummaryController extends ExtensionsController {
|
||||||
|
@override
|
||||||
|
Future<ExtensionsState> build() async => ExtensionsState(storeExtensions: [_summary()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DetailApi implements GopeedSiteApi {
|
||||||
|
late Future<StoreExtension> request;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<StoreExtension> getExtension(String id, {String? version}) => request;
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import 'package:gopeed/core/icons/gopeed_icons.dart';
|
|||||||
import 'package:gopeed/features/tasks/domain/task_record.dart';
|
import 'package:gopeed/features/tasks/domain/task_record.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
api.Task buildTask(List<int> selection, int size) => api.Task.fromJson({
|
api.Task buildTask(List<int> selection, int size, {String? doneAt}) => api.Task.fromJson({
|
||||||
'id': 'bt-selection',
|
'id': 'bt-selection',
|
||||||
'name': 'bundle',
|
'name': 'bundle',
|
||||||
'protocol': 'bt',
|
'protocol': 'bt',
|
||||||
@@ -12,6 +12,7 @@ void main() {
|
|||||||
'uploading': false,
|
'uploading': false,
|
||||||
'createdAt': '2026-01-01T00:00:00Z',
|
'createdAt': '2026-01-01T00:00:00Z',
|
||||||
'updatedAt': '2026-01-01T00:00:00Z',
|
'updatedAt': '2026-01-01T00:00:00Z',
|
||||||
|
'doneAt': doneAt,
|
||||||
'meta': {
|
'meta': {
|
||||||
'req': {'url': 'magnet:?xt=urn:btih:test'},
|
'req': {'url': 'magnet:?xt=urn:btih:test'},
|
||||||
'opts': {'path': '/downloads', 'selectFiles': selection},
|
'opts': {'path': '/downloads', 'selectFiles': selection},
|
||||||
@@ -43,6 +44,13 @@ void main() {
|
|||||||
expect(record.totalBytes, 60);
|
expect(record.totalBytes, 60);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('completion time maps from api and stays null for legacy tasks', () {
|
||||||
|
final completed = TaskRecord.fromApi(buildTask([], 60, doneAt: '2026-01-02T03:04:05Z'));
|
||||||
|
expect(completed.doneAt, DateTime.parse('2026-01-02T03:04:05Z'));
|
||||||
|
final legacy = TaskRecord.fromApi(buildTask([], 60));
|
||||||
|
expect(legacy.doneAt, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
test('file names preserve hash and question mark characters for type detection', () {
|
test('file names preserve hash and question mark characters for type detection', () {
|
||||||
expect(
|
expect(
|
||||||
taskFileTypeIcon('Slow English Podcast #2 _ Eating, Talking, and Learning Natural English.mp4'),
|
taskFileTypeIcon('Slow English Podcast #2 _ Eating, Talking, and Learning Natural English.mp4'),
|
||||||
|
|||||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user