mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-10 10:30:28 +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.
|
||||
|
||||
- [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.
|
||||
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ Gopeed を AI Agent と連携すると、自然言語でダウンロードを管
|
||||
|
||||
Gopeed 2.0.0 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから FFI で Go コアへ直接接続するネイティブ通信アーキテクチャ、より統一されたクロスプラットフォーム体験、強化されたタスク管理、より柔軟な API、MCP による AI Agent 連携機能を導入しています。一部の機能はまだ不完全または不安定な場合がありますので、ぜひお試しいただき、問題があればフィードバックをお寄せください。
|
||||
|
||||
- [Gopeed 2.0.0 Beta 2 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
|
||||
- [Gopeed 2.0.0 Beta 3 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
||||
|
||||
機能と安定性が正式リリースの基準に達した時点で、Gopeed 2.0.0 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。
|
||||
|
||||
|
||||
+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 đề.
|
||||
|
||||
- [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.
|
||||
|
||||
|
||||
+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 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 渠道。
|
||||
|
||||
|
||||
+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 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 頻道。
|
||||
|
||||
|
||||
+7
-3
@@ -7,9 +7,11 @@ import (
|
||||
)
|
||||
|
||||
type args struct {
|
||||
url string
|
||||
connections *int
|
||||
dir *string
|
||||
url string
|
||||
connections *int
|
||||
dir *string
|
||||
checksum *string
|
||||
checksumAlgo *string
|
||||
}
|
||||
|
||||
func parse() *args {
|
||||
@@ -20,6 +22,8 @@ func parse() *args {
|
||||
var args args
|
||||
args.connections = flag.Int("C", 16, "Concurrent connections.")
|
||||
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()
|
||||
t := flag.Args()
|
||||
if len(t) > 0 {
|
||||
|
||||
+17
-2
@@ -19,6 +19,18 @@ func main() {
|
||||
|
||||
var wg sync.WaitGroup
|
||||
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().
|
||||
URL(args.url).
|
||||
Listener(func(event *download.Event) {
|
||||
@@ -43,8 +55,11 @@ func main() {
|
||||
}
|
||||
}).
|
||||
Create(&base.Options{
|
||||
Path: *args.dir,
|
||||
Extra: http.OptsExtra{Connections: *args.connections},
|
||||
Path: *args.dir,
|
||||
Extra: http.OptsExtra{
|
||||
Connections: *args.connections,
|
||||
Checksum: checksumOpt,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -83,10 +83,15 @@ func TestWaitCleansUnselectedFilesFromSharedPiece(t *testing.T) {
|
||||
{Length: 4, Path: []string{"keep.txt"}},
|
||||
{Length: 4, Path: []string{"nested", "skip.txt"}},
|
||||
}}
|
||||
store := storage.NewFileOpts(storage.NewFileClientOpts{
|
||||
storageOpts := storage.NewFileClientOpts{
|
||||
ClientBaseDir: dir,
|
||||
TorrentDirMaker: func(baseDir string, _ *metainfo.Info, _ metainfo.Hash) string { return baseDir },
|
||||
})
|
||||
}
|
||||
// VerifyDataContext can return before storage finishes marking the piece
|
||||
// complete. Disable automatic part-file promotion so it cannot rename
|
||||
// the .part fixtures that this test creates to exercise cleanup.
|
||||
storageOpts.UsePartFiles.Set(false)
|
||||
store := storage.NewFileOpts(storageOpts)
|
||||
defer store.Close()
|
||||
config := torrent.TestingConfig(t)
|
||||
config.DisableTCP = true
|
||||
|
||||
@@ -2,8 +2,13 @@ package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -280,6 +285,12 @@ type Fetcher struct {
|
||||
primaryReadyOnce sync.Once
|
||||
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
|
||||
startPending atomic.Bool
|
||||
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
|
||||
}
|
||||
|
||||
// Get content length from Content-Length header
|
||||
contentLength := resp.Header.Get(base.HttpHeaderContentLength)
|
||||
if contentLength != "" {
|
||||
parse, err := strconv.ParseInt(contentLength, 10, 64)
|
||||
if err == nil {
|
||||
res.Size = parse
|
||||
// A partial response describes one segment, not the whole resource.
|
||||
reuseResolveBody := true
|
||||
if resp.StatusCode == base.HttpCodePartialContent {
|
||||
start, _, total, err := parseRangeResponse(resp)
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
f.setState(stateError)
|
||||
return err
|
||||
}
|
||||
res.Size = total
|
||||
res.Range = true
|
||||
// Prefetch is a contiguous prefix copied at offset zero. A segment
|
||||
// starting elsewhere must be fetched again through the Range path.
|
||||
reuseResolveBody = start == 0
|
||||
} else {
|
||||
contentLength := resp.Header.Get(base.HttpHeaderContentLength)
|
||||
if contentLength != "" {
|
||||
if size, err := strconv.ParseInt(contentLength, 10, 64); err == nil {
|
||||
res.Size = size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// This is crucial for one-time URLs that can only be accessed once
|
||||
f.resolveRespLock.Lock()
|
||||
f.resolveResp = resp
|
||||
if reuseResolveBody {
|
||||
f.resolveResp = resp
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
f.prefetchDone.Store(true)
|
||||
}
|
||||
f.resolveRespLock.Unlock()
|
||||
|
||||
f.setState(stateResolved)
|
||||
@@ -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)
|
||||
// For non-range resources, the response will be used directly in Start
|
||||
if res.Range {
|
||||
if res.Range && reuseResolveBody {
|
||||
f.prefetchStopCh = make(chan struct{})
|
||||
f.resolveFallback.Store(true)
|
||||
go f.asyncPrefetch()
|
||||
@@ -716,12 +746,26 @@ func (f *Fetcher) doStart() error {
|
||||
|
||||
// If retrying after error, reset connection states for retry
|
||||
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()
|
||||
for _, conn := range f.connections {
|
||||
// Reset connections that can be retried
|
||||
if !conn.Completed && conn.State != connCompleted {
|
||||
// On a normal error, only reset connections that didn't
|
||||
// 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) {
|
||||
f.resetConnectionForRestart(conn)
|
||||
f.resetConnectionForRestart(conn, forceFullReset)
|
||||
}
|
||||
conn.State = connNotStarted
|
||||
conn.failed = false
|
||||
@@ -951,8 +995,14 @@ func (f *Fetcher) expandConnections() {
|
||||
}
|
||||
f.fileMu.Unlock()
|
||||
|
||||
f.setState(stateDone)
|
||||
f.doneCh <- nil
|
||||
var err error
|
||||
if chkErr := f.verifyChecksum(); chkErr != nil {
|
||||
err = chkErr
|
||||
f.setState(stateError)
|
||||
} else {
|
||||
f.setState(stateDone)
|
||||
}
|
||||
f.doneCh <- err
|
||||
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.
|
||||
f.connMu.Lock()
|
||||
if !f.hasSequentialPrefixLocked(conn) {
|
||||
f.resetConnectionForRestart(conn)
|
||||
f.resetConnectionForRestart(conn, false)
|
||||
}
|
||||
f.connMu.Unlock()
|
||||
}
|
||||
@@ -1195,7 +1245,7 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
|
||||
resumeProbe := f.canProbeSequentialResumeLocked(conn)
|
||||
intentionalRestart := sequentialSizeUnknown
|
||||
if sequentialSizeUnknown || (hasSequentialPrefix && !resumeProbe) {
|
||||
f.resetConnectionForRestart(conn)
|
||||
f.resetConnectionForRestart(conn, false)
|
||||
f.resolveDataPos.Store(0)
|
||||
f.rangeValidatorPinned = false
|
||||
intentionalRestart = true
|
||||
@@ -1544,27 +1594,41 @@ func (f *Fetcher) completeFromResolveFallback(conn *connection) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func validateRangeResponse(resp *http.Response, requestedStart int64, requestedEnd *int64, totalSize int64) (int64, int64, error) {
|
||||
// parseRangeResponse validates a single byte range with a known complete length.
|
||||
// Unknown totals are rejected: a segment length cannot stand in for resource size.
|
||||
func parseRangeResponse(resp *http.Response) (int64, int64, int64, error) {
|
||||
contentRange := resp.Header.Get(base.HttpHeaderContentRange)
|
||||
fields := strings.Fields(contentRange)
|
||||
if len(fields) != 2 || !strings.EqualFold(fields[0], base.HttpHeaderBytes) {
|
||||
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
||||
return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
||||
}
|
||||
|
||||
rangeAndTotal := strings.Split(fields[1], "/")
|
||||
if len(rangeAndTotal) != 2 {
|
||||
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
||||
return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
||||
}
|
||||
bounds := strings.Split(rangeAndTotal[0], "-")
|
||||
if len(bounds) != 2 {
|
||||
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
||||
return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
||||
}
|
||||
|
||||
start, startErr := strconv.ParseInt(bounds[0], 10, 64)
|
||||
end, endErr := strconv.ParseInt(bounds[1], 10, 64)
|
||||
total, totalErr := strconv.ParseInt(rangeAndTotal[1], 10, 64)
|
||||
if startErr != nil || endErr != nil || totalErr != nil || start < 0 || end < start || total <= end {
|
||||
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
||||
return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
|
||||
}
|
||||
expectedLength := end - start + 1
|
||||
if resp.ContentLength >= 0 && resp.ContentLength != expectedLength {
|
||||
return 0, 0, 0, fmt.Errorf("%w: Content-Length %d does not match range length %d", errInvalidRangeResponse, resp.ContentLength, expectedLength)
|
||||
}
|
||||
return start, end, total, nil
|
||||
}
|
||||
|
||||
func validateRangeResponse(resp *http.Response, requestedStart int64, requestedEnd *int64, totalSize int64) (int64, int64, error) {
|
||||
start, end, total, err := parseRangeResponse(resp)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if start != requestedStart || (requestedEnd != nil && end != *requestedEnd) || (requestedEnd == nil && end != total-1) {
|
||||
return 0, 0, fmt.Errorf("%w: Content-Range %d-%d does not match requested range", errInvalidRangeResponse, start, end)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
expectedLength := end - start + 1
|
||||
if resp.ContentLength >= 0 && resp.ContentLength != expectedLength {
|
||||
return 0, 0, fmt.Errorf("%w: Content-Length %d does not match range length %d", errInvalidRangeResponse, resp.ContentLength, expectedLength)
|
||||
}
|
||||
return expectedLength, total, nil
|
||||
return end - start + 1, total, nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) resolveOpenEndedRange(conn *connection, totalSize int64) error {
|
||||
@@ -1630,7 +1690,7 @@ func (f *Fetcher) fallbackToSequentialDownload(conn *connection, ifRange string)
|
||||
f.rangeReprobeEligible = true
|
||||
f.rangeValidatorPinned = false
|
||||
f.ifRange = ifRange
|
||||
f.resetConnectionForRestart(conn)
|
||||
f.resetConnectionForRestart(conn, false)
|
||||
f.resolveDataPos.Store(0)
|
||||
return nil
|
||||
}
|
||||
@@ -1646,7 +1706,7 @@ func (f *Fetcher) restartSequentialDownload(conn *connection, ifRange string) {
|
||||
f.rangeReprobeEligible = true
|
||||
f.rangeValidatorPinned = false
|
||||
f.ifRange = ifRange
|
||||
f.resetConnectionForRestart(conn)
|
||||
f.resetConnectionForRestart(conn, false)
|
||||
f.resolveDataPos.Store(0)
|
||||
}
|
||||
|
||||
@@ -2029,16 +2089,25 @@ func (f *Fetcher) helpOtherConnection(helper *connection) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (f *Fetcher) resetConnectionForRestart(conn *connection) {
|
||||
if f.meta.Res.Range {
|
||||
func (f *Fetcher) resetConnectionForRestart(conn *connection, force bool) {
|
||||
if f.meta.Res.Range && !force {
|
||||
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,
|
||||
// so pause/retry must restart instead of continuing from the old offset.
|
||||
if conn.Chunk == nil {
|
||||
conn.Chunk = newChunk(0, 0)
|
||||
} else {
|
||||
} else if !f.meta.Res.Range {
|
||||
conn.Chunk.Begin = 0
|
||||
conn.Chunk.setEnd(0)
|
||||
conn.Chunk.Downloaded = 0
|
||||
@@ -2102,7 +2171,7 @@ func (f *Fetcher) resumeConnections() {
|
||||
}
|
||||
}
|
||||
if !f.hasSequentialPrefixLocked(conn) {
|
||||
f.resetConnectionForRestart(conn)
|
||||
f.resetConnectionForRestart(conn, false)
|
||||
}
|
||||
// Reset the connection state for resume
|
||||
conn.ctx, conn.cancel = context.WithCancel(f.ctx)
|
||||
@@ -2209,10 +2278,15 @@ func (f *Fetcher) onDownloadComplete() {
|
||||
}
|
||||
f.fileMu.Unlock()
|
||||
|
||||
if finalErr != nil {
|
||||
f.setState(stateError)
|
||||
if finalErr == nil {
|
||||
if chkErr := f.verifyChecksum(); chkErr != nil {
|
||||
finalErr = chkErr
|
||||
f.setState(stateError)
|
||||
} else {
|
||||
f.setState(stateDone)
|
||||
}
|
||||
} else {
|
||||
f.setState(stateDone)
|
||||
f.setState(stateError)
|
||||
}
|
||||
|
||||
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 {
|
||||
// Check if all data has been downloaded
|
||||
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"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -853,22 +854,34 @@ func TestFetcher_InitialIgnoredRangeUsesResponseSize(t *testing.T) {
|
||||
resolvePayload := bytes.Repeat([]byte("resolve"), 64*1024)
|
||||
replacement := bytes.Repeat([]byte("replacement"), 24*1024)
|
||||
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) {
|
||||
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||
if r.Header.Get(base.HttpHeaderRange) == "" {
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(resolvePayload)))
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
rangeRequests.Add(1)
|
||||
releaseResolve()
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(replacement)))
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write(replacement)
|
||||
}))
|
||||
defer server.Close()
|
||||
defer releaseResolve()
|
||||
|
||||
f := buildFetcher()
|
||||
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"`
|
||||
}
|
||||
|
||||
// SetLabels replaces all labels on the request.
|
||||
func (r *Request) SetLabels(labels map[string]string) {
|
||||
r.Labels = labels
|
||||
}
|
||||
|
||||
// PutLabel sets a label on the request.
|
||||
func (r *Request) PutLabel(key, value string) {
|
||||
if r.Labels == nil {
|
||||
r.Labels = make(map[string]string)
|
||||
}
|
||||
r.Labels[key] = value
|
||||
}
|
||||
|
||||
// DelLabel deletes a label from the request.
|
||||
func (r *Request) DelLabel(key string) {
|
||||
delete(r.Labels, key)
|
||||
}
|
||||
|
||||
func (r *Request) Validate() error {
|
||||
if r.URL == "" {
|
||||
return fmt.Errorf("invalid request url")
|
||||
|
||||
@@ -1912,6 +1912,7 @@ func (d *Downloader) doPause(task *Task) (err error) {
|
||||
}
|
||||
if handled {
|
||||
d.emit(EventKeyPause, task)
|
||||
d.triggerWebhooks(WebhookEventDownloadPause, task, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -45,6 +45,32 @@ type generationTestManager struct {
|
||||
|
||||
func TestExtensionTaskMethodsMatchEventCapabilities(t *testing.T) {
|
||||
taskType := reflect.TypeOf((*Task)(nil))
|
||||
for _, typ := range []reflect.Type{taskType, reflect.TypeOf((*ExtensionTask)(nil)), reflect.TypeOf((*OnErrorExtensionTask)(nil))} {
|
||||
for _, name := range []string{"SetUrl", "SetExtra", "PatchExtra", "PatchHeaders", "SetMethod", "SetBody", "SetHeaders", "PutHeader", "DelHeader", "SetTrackers"} {
|
||||
if _, ok := typ.MethodByName(name); ok {
|
||||
t.Fatalf("%v unexpectedly exposes %s", typ, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
requestType := reflect.TypeOf((*ExtensionTaskRequest)(nil))
|
||||
for _, name := range []string{"SetUrl", "SetLabels", "PutLabel", "DelLabel", "SetMethod", "SetBody", "SetHeaders", "PutHeader", "DelHeader", "SetTrackers"} {
|
||||
if _, ok := requestType.MethodByName(name); !ok {
|
||||
t.Fatalf("request wrapper does not expose %s", name)
|
||||
}
|
||||
}
|
||||
for _, typ := range []reflect.Type{reflect.TypeOf(ExtensionTask{}), reflect.TypeOf(OnErrorExtensionTask{})} {
|
||||
if _, ok := typ.FieldByName("Req"); ok {
|
||||
t.Fatalf("%v exposes the removed task.req entry", typ)
|
||||
}
|
||||
field, ok := typ.FieldByName("Meta")
|
||||
if !ok || field.Type != reflect.TypeOf((*ExtensionTaskMeta)(nil)) {
|
||||
t.Fatalf("%v has no metadata wrapper", typ)
|
||||
}
|
||||
}
|
||||
field, ok := reflect.TypeOf(ExtensionTaskMeta{}).FieldByName("Req")
|
||||
if !ok || field.Type != requestType {
|
||||
t.Fatal("meta.req has no request wrapper")
|
||||
}
|
||||
if _, ok := taskType.MethodByName("Continue"); ok {
|
||||
t.Fatal("regular task unexpectedly exposes Continue")
|
||||
}
|
||||
@@ -53,17 +79,11 @@ func TestExtensionTaskMethodsMatchEventCapabilities(t *testing.T) {
|
||||
}
|
||||
|
||||
extensionTaskType := reflect.TypeOf((*ExtensionTask)(nil))
|
||||
if _, ok := extensionTaskType.MethodByName("SetUrl"); !ok {
|
||||
t.Fatal("extension task does not expose SetUrl")
|
||||
}
|
||||
if _, ok := extensionTaskType.MethodByName("Continue"); ok {
|
||||
t.Fatal("non-error extension task unexpectedly exposes Continue")
|
||||
}
|
||||
|
||||
errorTaskType := reflect.TypeOf((*OnErrorExtensionTask)(nil))
|
||||
if _, ok := errorTaskType.MethodByName("SetUrl"); !ok {
|
||||
t.Fatal("onError task does not expose SetUrl")
|
||||
}
|
||||
if _, ok := errorTaskType.MethodByName("Continue"); !ok {
|
||||
t.Fatal("onError task does not expose Continue")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method == http.MethodPost {
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
if (name === "set-cookie") {
|
||||
for (const value of values) result.push([name, value]);
|
||||
} else {
|
||||
result.push([name, values.join(", ")]);
|
||||
result.push([name, values.join(name === "cookie" ? "; " : ", ")]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -158,49 +158,10 @@
|
||||
return iterator;
|
||||
}
|
||||
|
||||
function forbiddenRequestHeader(name, value) {
|
||||
if (name.startsWith("proxy-") || name.startsWith("sec-")) return true;
|
||||
if ([
|
||||
"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) {
|
||||
function assertMutableHeaders(headers) {
|
||||
// Match Node/Undici: HTTP syntax and immutability still apply, but browser
|
||||
// forbidden-header and no-cors safelist rules do not apply to extensions.
|
||||
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 {
|
||||
@@ -235,27 +196,21 @@
|
||||
name = normalizeHeaderName(name);
|
||||
value = normalizeHeaderValue(value);
|
||||
const values = this._values.get(name);
|
||||
const combinedValue = values && name !== "set-cookie" ? values.join(", ") + ", " + value : value;
|
||||
if (blockedByGuard(this, name, combinedValue)) return;
|
||||
assertMutableHeaders(this);
|
||||
if (values) values.push(value);
|
||||
else this._values.set(name, [value]);
|
||||
}
|
||||
|
||||
delete(name) {
|
||||
name = normalizeHeaderName(name);
|
||||
if (this._guard === "immutable") throw new TypeError("Headers are immutable");
|
||||
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;
|
||||
name = normalizeHeaderName(name);
|
||||
assertMutableHeaders(this);
|
||||
this._values.delete(name);
|
||||
}
|
||||
|
||||
get(name) {
|
||||
const values = this._values.get(normalizeHeaderName(name));
|
||||
return values ? values.join(", ") : null;
|
||||
name = normalizeHeaderName(name);
|
||||
const values = this._values.get(name);
|
||||
return values ? values.join(name === "cookie" ? "; " : ", ") : null;
|
||||
}
|
||||
|
||||
getSetCookie() {
|
||||
@@ -268,10 +223,10 @@
|
||||
}
|
||||
|
||||
set(name, value) {
|
||||
name = normalizeHeaderName(name);
|
||||
value = normalizeHeaderValue(value);
|
||||
if (blockedByGuard(this, name, value)) return;
|
||||
this._values.set(name, [value]);
|
||||
name = normalizeHeaderName(name);
|
||||
value = normalizeHeaderValue(value);
|
||||
assertMutableHeaders(this);
|
||||
this._values.set(name, [value]);
|
||||
}
|
||||
|
||||
keys() { return createHeadersIterator(this, "key"); }
|
||||
@@ -484,7 +439,7 @@
|
||||
}
|
||||
this._duplex = "half";
|
||||
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);
|
||||
if (source && !bodyWasOverridden && source.body) body = transferReadableStream(source.body);
|
||||
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
|
||||
}
|
||||
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])
|
||||
}
|
||||
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
|
||||
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
|
||||
construction and body consumption, immutable network response headers, and
|
||||
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,
|
||||
browser HTTP cache, navigation, browser authentication UI, or WPT's
|
||||
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
|
||||
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
|
||||
from WPT. Test logic below `fetch/` is unchanged; the checked-in copy may only
|
||||
normalize a final newline.
|
||||
@@ -20,9 +20,6 @@ var wptFetchUtils string
|
||||
//go:embed testdata/wpt/fetch/api/request/request-error.js
|
||||
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
|
||||
var wptHeaders embed.FS
|
||||
|
||||
@@ -30,31 +27,19 @@ var wptHeaders embed.FS
|
||||
var wptRequestResponse embed.FS
|
||||
|
||||
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{
|
||||
"header-setcookie.any.js",
|
||||
"headers-basic.any.js",
|
||||
"headers-casing.any.js",
|
||||
"headers-combine.any.js",
|
||||
"headers-errors.any.js",
|
||||
"headers-forbidden-override.any.js",
|
||||
"headers-normalize.any.js",
|
||||
"headers-no-cors.any.js",
|
||||
"headers-record.any.js",
|
||||
"headers-structure.any.js",
|
||||
}
|
||||
for _, name := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
setup := ""
|
||||
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)
|
||||
runWPTFile(t, wptHeaders, path.Join("testdata/wpt/fetch/api/headers", name), "")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -110,7 +95,30 @@ func runWPTFile(t *testing.T, files embed.FS, name, setup string) {
|
||||
}
|
||||
runtime := engine.NewEngine(nil)
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/logger"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||
@@ -733,6 +734,14 @@ type OnDoneContext struct {
|
||||
// only some fields can be modified, such as request info.
|
||||
type ExtensionTask struct {
|
||||
*Task
|
||||
Meta *ExtensionTaskMeta `json:"meta"`
|
||||
}
|
||||
|
||||
// ExtensionTaskMeta preserves metadata fields while exposing request operations
|
||||
// at the existing meta.req path.
|
||||
type ExtensionTaskMeta struct {
|
||||
*fetcher.FetcherMeta
|
||||
Req *ExtensionTaskRequest `json:"req"`
|
||||
}
|
||||
|
||||
// OnErrorExtensionTask adds error-recovery controls to ExtensionTask.
|
||||
@@ -750,7 +759,14 @@ func cloneExtensionTask(task *Task) *Task {
|
||||
}
|
||||
|
||||
func newExtensionTask(task *Task) *ExtensionTask {
|
||||
return &ExtensionTask{Task: cloneExtensionTask(task)}
|
||||
view := cloneExtensionTask(task)
|
||||
return &ExtensionTask{
|
||||
Task: view,
|
||||
Meta: &ExtensionTaskMeta{
|
||||
FetcherMeta: view.Meta,
|
||||
Req: &ExtensionTaskRequest{Request: view.Meta.Req, task: view},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newOnStartExtensionTask(task *Task) *ExtensionTask {
|
||||
@@ -768,11 +784,6 @@ func newOnDoneExtensionTask(task *Task) *Task {
|
||||
return cloneExtensionTask(task)
|
||||
}
|
||||
|
||||
// SetUrl replaces the task request URL.
|
||||
func (t *ExtensionTask) SetUrl(url string) {
|
||||
t.Meta.Req.URL = url
|
||||
}
|
||||
|
||||
func (t *OnErrorExtensionTask) Continue() error {
|
||||
return t.download.Continue(&TaskFilter{
|
||||
IDs: []string{t.ID},
|
||||
|
||||
@@ -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"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/logger"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
@@ -38,13 +40,13 @@ func TestDownloader_InstallExtensionByFolder(t *testing.T) {
|
||||
t.Fatal("resolve error")
|
||||
}
|
||||
if req.Labels["replaced"] != "true" || req.Labels["modified"] != "true" {
|
||||
t.Fatalf("request label methods did not update labels: %#v", req.Labels)
|
||||
t.Fatalf("declarative labels did not update: %#v", req.Labels)
|
||||
}
|
||||
if _, ok := req.Labels["original"]; ok {
|
||||
t.Fatalf("setLabels did not replace existing labels: %#v", req.Labels)
|
||||
t.Fatalf("label assignment did not replace existing labels: %#v", req.Labels)
|
||||
}
|
||||
if _, ok := req.Labels["removed"]; ok {
|
||||
t.Fatalf("delLabel did not remove label: %#v", req.Labels)
|
||||
t.Fatalf("declarative label deletion failed: %#v", req.Labels)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1188,6 +1190,37 @@ func TestDownloader_UpgradeExtension(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDownloader_Extension_OnStartExtraMutation(t *testing.T) {
|
||||
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir()})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Clear()
|
||||
if _, err := d.InstallExtensionByFolder("./testdata/extensions/on_start", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, extra := range []any{nil, &httpProtocol.ReqExtra{
|
||||
Method: "POST", Body: "old body", Header: map[string]string{"Old": "value"},
|
||||
}} {
|
||||
req := &base.Request{URL: "https://github.com/test", Extra: extra}
|
||||
task := &Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
|
||||
d.triggerOnStart(task)
|
||||
if err := base.ParseReqExtra[httpProtocol.ReqExtra](req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := req.Extra.(*httpProtocol.ReqExtra)
|
||||
if !ok || got.Header["X-Gopeed-Test"] != "on-start" {
|
||||
t.Fatalf("setHeaders did not update the original request: %#v", req.Extra)
|
||||
}
|
||||
if len(got.Header) != 1 {
|
||||
t.Fatalf("headers not replaced: %#v", got)
|
||||
}
|
||||
if original, ok := extra.(*httpProtocol.ReqExtra); ok && (got.Method != original.Method || got.Body != original.Body) {
|
||||
t.Fatalf("setHeaders changed unrelated fields: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_Extension_OnStart(t *testing.T) {
|
||||
downloadAndCheck := func(req *base.Request) {
|
||||
setupDownloader(func(downloader *Downloader) {
|
||||
|
||||
@@ -23,6 +23,9 @@ type ResolveResult struct {
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
Protocol string `json:"protocol"`
|
||||
@@ -32,6 +35,10 @@ type Task struct {
|
||||
Progress *Progress `json:"progress"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
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
|
||||
producerReceived int64
|
||||
@@ -134,6 +141,12 @@ func (t *Task) MarshalJSON() ([]byte, error) {
|
||||
func (t *Task) updateStatus(status base.Status) {
|
||||
t.UpdatedAt = time.Now()
|
||||
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 {
|
||||
|
||||
+6
-3
@@ -1,7 +1,10 @@
|
||||
gopeed.events.onResolve(async function (ctx) {
|
||||
await ctx.req.setLabels({ replaced: "true", removed: "true" });
|
||||
await ctx.req.putLabel("modified", "true");
|
||||
await ctx.req.delLabel("removed");
|
||||
for (const method of ['setUrl', 'setLabels', 'putLabel', 'delLabel', 'setMethod', 'putHeader', 'setTrackers']) {
|
||||
if (typeof ctx.req[method] !== 'undefined') throw new Error(`onResolve exposes ${method}`);
|
||||
}
|
||||
ctx.req.labels = { replaced: "true", removed: "true" };
|
||||
ctx.req.labels.modified = "true";
|
||||
delete ctx.req.labels.removed;
|
||||
ctx.res = {
|
||||
name: "test",
|
||||
files: Array(2).fill(true).map((_, i) => ({
|
||||
|
||||
@@ -132,7 +132,7 @@ gopeed.events.onError(async function (ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.task.setUrl(await createPayloadURL(source, false));
|
||||
await ctx.task.meta.req.setUrl(await createPayloadURL(source, false));
|
||||
await req.putLabel('started', 'true');
|
||||
await req.putLabel('rebuilt', 'true');
|
||||
await ctx.task.continue();
|
||||
|
||||
+1
-1
@@ -41,6 +41,6 @@ gopeed.events.onError(async function (ctx) {
|
||||
}
|
||||
|
||||
await req.putLabel("recovered", "true");
|
||||
await ctx.task.setUrl(await gopeed.runtime.blob.createObjectURL(new Blob(["ok\n"], { type: "text/plain" })));
|
||||
await ctx.task.meta.req.setUrl(await gopeed.runtime.blob.createObjectURL(new Blob(["ok\n"], { type: "text/plain" })));
|
||||
await ctx.task.continue();
|
||||
});
|
||||
+1
-1
@@ -46,7 +46,7 @@ gopeed.events.onError(async function (ctx) {
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.task.setUrl(await createRangeBlobUrl(req.rawUrl));
|
||||
await ctx.task.meta.req.setUrl(await createRangeBlobUrl(req.rawUrl));
|
||||
await req.putLabel('started', 'true');
|
||||
await req.putLabel('rebuilt', 'true');
|
||||
await ctx.task.continue();
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
gopeed.events.onError(async function (ctx) {
|
||||
gopeed.logger.info("url", ctx.task.meta.req.url);
|
||||
gopeed.logger.info("error", ctx.error);
|
||||
await ctx.task.setUrl("https://github.com");
|
||||
await ctx.task.meta.req.setUrl("https://github.com");
|
||||
await ctx.task.continue();
|
||||
});
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
gopeed.events.onStart(async function (ctx) {
|
||||
gopeed.logger.info("url", ctx.task.meta.req.url);
|
||||
await ctx.task.setUrl("https://github.com");
|
||||
await ctx.task.meta.req.setUrl("https://github.com");
|
||||
await ctx.task.meta.req.setHeaders({ "X-Gopeed-Test": "on-start" });
|
||||
await ctx.task.meta.req.putLabel('modified', 'true');
|
||||
});
|
||||
@@ -21,6 +21,7 @@ type WebhookEvent string
|
||||
const (
|
||||
WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE"
|
||||
WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
|
||||
WebhookEventDownloadPause WebhookEvent = "DOWNLOAD_PAUSE"
|
||||
)
|
||||
|
||||
// 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)) {
|
||||
defaultDownloader.Setup()
|
||||
defaultDownloader.cfg.StorageDir = ".test_storage"
|
||||
|
||||
+67
-7
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
phttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
@@ -65,12 +66,13 @@ type btRequestExtra struct {
|
||||
}
|
||||
|
||||
type httpTaskExtra struct {
|
||||
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"`
|
||||
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"`
|
||||
ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"`
|
||||
DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"`
|
||||
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"`
|
||||
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"`
|
||||
ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"`
|
||||
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
|
||||
@@ -129,6 +131,11 @@ func isBitTorrentRequest(rawURL string) bool {
|
||||
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
|
||||
// defines protocol-specific task options, so Extra has one concrete schema.
|
||||
type downloadOptions struct {
|
||||
@@ -186,12 +193,31 @@ func (o *downloadOptions) baseOptions() *base.Options {
|
||||
if o == 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{
|
||||
Name: o.Name,
|
||||
Path: o.Path,
|
||||
AsDefaultPath: o.AsDefaultPath,
|
||||
SelectFiles: o.SelectFiles,
|
||||
Extra: o.Extra,
|
||||
Extra: extra,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +296,13 @@ type getTaskStatsOutput struct {
|
||||
Stats any `json:"stats"`
|
||||
}
|
||||
|
||||
type getDownloadSpeedInput struct{}
|
||||
|
||||
type getDownloadSpeedOutput struct {
|
||||
TotalSpeed int64 `json:"totalSpeed"`
|
||||
ActiveTaskCount int `json:"activeTaskCount"`
|
||||
}
|
||||
|
||||
type taskActionOutput struct {
|
||||
ID string `json:"id"`
|
||||
Success bool `json:"success"`
|
||||
@@ -416,6 +449,33 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) {
|
||||
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, "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",
|
||||
"create_task",
|
||||
"delete_task",
|
||||
"get_download_speed",
|
||||
"get_task",
|
||||
"get_task_stats",
|
||||
"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"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
@@ -21,6 +28,8 @@ type OptsExtra struct {
|
||||
ArchivePassword string `json:"archivePassword"`
|
||||
// DeleteAfterExtract when true, deletes the archive file after successful extraction
|
||||
DeleteAfterExtract bool `json:"deleteAfterExtract"`
|
||||
// Checksum verifies the completed file against an expected hash after download
|
||||
Checksum *ChecksumOption `json:"checksum,omitempty"`
|
||||
}
|
||||
|
||||
// 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:
|
||||
|
||||
- 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`
|
||||
- Main content header height: `80px`
|
||||
- Window drag header height on Windows: `30px`
|
||||
|
||||
@@ -85,6 +85,12 @@ Appearance synchronization uses `appearance.changed` and currently contains:
|
||||
- Theme color
|
||||
- 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.
|
||||
|
||||
## 6. Storage
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
3EF29839EA13551EB8144269 /* GopeedLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */; };
|
||||
B30000000000000000000001 /* GopeedTaskEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B30000000000000000000002 /* GopeedTaskEvent.swift */; };
|
||||
6085D504396791024CEC496B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8823E31DA135C410E362683E /* Pods_RunnerTests.framework */; };
|
||||
6243BE8F995F5284DBDD7DE3 /* GopeedLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
@@ -102,6 +103,7 @@
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedLiveActivityWidget.swift; sourceTree = "<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>"; };
|
||||
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>"; };
|
||||
@@ -286,6 +288,7 @@
|
||||
09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */,
|
||||
EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */,
|
||||
3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */,
|
||||
B30000000000000000000002 /* GopeedTaskEvent.swift */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
@@ -607,6 +610,7 @@
|
||||
1E29080079AAFD1CF2C71262 /* GopeedDownloadAttributes.swift in Sources */,
|
||||
032447D610CFAF8A75642212 /* GopeedLiveActivityManager.swift in Sources */,
|
||||
5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */,
|
||||
B30000000000000000000001 /* GopeedTaskEvent.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -22,8 +22,8 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
|
||||
// MARK: - BGCPT state
|
||||
|
||||
private var registeredIdentifiers = Set<String>()
|
||||
private var pendingTaskIDs = Set<String>()
|
||||
private var foregroundStartTokens: [String: UUID] = [:]
|
||||
private struct ExpiringTaskState {
|
||||
let identifier: String
|
||||
let generation: UUID
|
||||
@@ -167,16 +167,11 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
// Pre-mark task.start so the Objective-C forwarder can
|
||||
// immediately suppress the custom ActivityKit Live Activity.
|
||||
if
|
||||
let data = payload.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(
|
||||
with: data
|
||||
) as? [String: Any],
|
||||
let type = json["type"] as? String,
|
||||
type == "task.start",
|
||||
let taskID = json["taskId"] as? String
|
||||
let event = GopeedTaskEvent.decode(payload),
|
||||
event.type == .start
|
||||
{
|
||||
setTaskOwnership(
|
||||
taskID: taskID,
|
||||
taskID: event.taskID,
|
||||
handled: true
|
||||
)
|
||||
}
|
||||
@@ -198,33 +193,19 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
guard
|
||||
let data = payload.data(
|
||||
using: .utf8
|
||||
),
|
||||
let json =
|
||||
try? JSONSerialization
|
||||
.jsonObject(
|
||||
with: data
|
||||
) as? [String: Any],
|
||||
let type =
|
||||
json["type"] as? String,
|
||||
let taskID =
|
||||
json["taskId"] as? String
|
||||
else {
|
||||
guard let event = GopeedTaskEvent.decode(payload) else {
|
||||
print(
|
||||
"ContinuedProcessing: invalid event"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let name =
|
||||
json["name"] as? String
|
||||
?? "Download"
|
||||
let taskID = event.taskID
|
||||
let name = event.name
|
||||
|
||||
switch type {
|
||||
switch event.type {
|
||||
|
||||
case "task.start":
|
||||
case .start:
|
||||
progressRecoveryTokens.removeValue(
|
||||
forKey: taskID
|
||||
)
|
||||
@@ -234,7 +215,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
name: name
|
||||
)
|
||||
|
||||
case "task.progress":
|
||||
case .progress:
|
||||
if let generation =
|
||||
taskGenerations[taskID] {
|
||||
updateProgress(
|
||||
@@ -249,7 +230,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
case "task.pause":
|
||||
case .pause:
|
||||
if let expiring =
|
||||
expiringTasks[taskID] {
|
||||
finishExpirationCleanup(
|
||||
@@ -265,7 +246,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
case "task.done":
|
||||
case .done:
|
||||
finishTask(
|
||||
taskID: taskID,
|
||||
success: true,
|
||||
@@ -273,7 +254,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
"Download complete"
|
||||
)
|
||||
|
||||
case "task.error":
|
||||
case .error:
|
||||
finishTask(
|
||||
taskID: taskID,
|
||||
success: false,
|
||||
@@ -281,7 +262,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
"Download failed"
|
||||
)
|
||||
|
||||
case "task.delete":
|
||||
case .delete:
|
||||
finishTask(
|
||||
taskID: taskID,
|
||||
success: true,
|
||||
@@ -289,12 +270,11 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
"Download removed"
|
||||
)
|
||||
|
||||
default:
|
||||
case .other:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Recover active task after setting changes
|
||||
|
||||
private func recoverTaskFromProgress(
|
||||
@@ -307,6 +287,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
!pendingTaskIDs.contains(taskID),
|
||||
activeTasks[taskID] == nil,
|
||||
expiringTasks[taskID] == nil,
|
||||
foregroundStartTokens[taskID] == nil,
|
||||
progressRecoveryTokens[taskID] == nil
|
||||
else {
|
||||
return
|
||||
@@ -395,7 +376,8 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
|
||||
guard
|
||||
!pendingTaskIDs.contains(taskID),
|
||||
activeTasks[taskID] == nil
|
||||
activeTasks[taskID] == nil,
|
||||
foregroundStartTokens[taskID] == nil
|
||||
else {
|
||||
setTaskOwnership(
|
||||
taskID: taskID,
|
||||
@@ -404,20 +386,57 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
// BGCPT should originate from a foreground user action.
|
||||
// This main-thread check happens only once per task start.
|
||||
let appIsActive: Bool
|
||||
// BGCPT must originate from a foreground user action.
|
||||
// Never synchronously hop from workerQueue to the main queue:
|
||||
// doing so can deadlock if the main thread is waiting on work
|
||||
// that eventually needs workerQueue.
|
||||
let foregroundToken = UUID()
|
||||
foregroundStartTokens[taskID] =
|
||||
foregroundToken
|
||||
|
||||
if Thread.isMainThread {
|
||||
appIsActive =
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
let appIsActive =
|
||||
UIApplication.shared
|
||||
.applicationState == .active
|
||||
} else {
|
||||
appIsActive =
|
||||
DispatchQueue.main.sync {
|
||||
UIApplication.shared
|
||||
.applicationState == .active
|
||||
}
|
||||
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.workerQueue.async {
|
||||
self.continueBeginTaskAfterForegroundCheck(
|
||||
taskID: taskID,
|
||||
name: name,
|
||||
foregroundToken: foregroundToken,
|
||||
appIsActive: appIsActive
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func continueBeginTaskAfterForegroundCheck(
|
||||
taskID: String,
|
||||
name: String,
|
||||
foregroundToken: UUID,
|
||||
appIsActive: Bool
|
||||
) {
|
||||
guard
|
||||
foregroundStartTokens[taskID]
|
||||
== foregroundToken
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
foregroundStartTokens.removeValue(
|
||||
forKey: taskID
|
||||
)
|
||||
|
||||
guard currentEnabledSnapshot() else {
|
||||
setTaskOwnership(
|
||||
taskID: taskID,
|
||||
handled: false
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard appIsActive else {
|
||||
@@ -434,6 +453,20 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
// Re-check after the asynchronous main-thread hop. A terminal
|
||||
// event, disable, or another valid start may have changed the
|
||||
// lifecycle while the foreground state was being read.
|
||||
guard
|
||||
!pendingTaskIDs.contains(taskID),
|
||||
activeTasks[taskID] == nil
|
||||
else {
|
||||
setTaskOwnership(
|
||||
taskID: taskID,
|
||||
handled: true
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let generation = UUID()
|
||||
taskGenerations[taskID] = generation
|
||||
|
||||
@@ -498,8 +531,6 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
registeredIdentifiers.insert(identifier)
|
||||
|
||||
let request =
|
||||
BGContinuedProcessingTaskRequest(
|
||||
identifier: identifier,
|
||||
@@ -558,7 +589,6 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - System launched task
|
||||
|
||||
private func activateTask(
|
||||
@@ -901,6 +931,10 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
success: Bool,
|
||||
finalSubtitle: String
|
||||
) {
|
||||
foregroundStartTokens.removeValue(
|
||||
forKey: taskID
|
||||
)
|
||||
|
||||
progressRecoveryTokens.removeValue(
|
||||
forKey: taskID
|
||||
)
|
||||
@@ -1115,6 +1149,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
Set(
|
||||
handledTaskIDsSnapshot()
|
||||
+ Array(taskIdentifiers.keys)
|
||||
+ Array(foregroundStartTokens.keys)
|
||||
+ Array(pendingTaskIDs)
|
||||
+ Array(activeTasks.keys)
|
||||
+ Array(expiringTasks.keys)
|
||||
@@ -1140,6 +1175,7 @@ final class GopeedContinuedProcessingManager: NSObject {
|
||||
}
|
||||
|
||||
activeTasks.removeAll()
|
||||
foregroundStartTokens.removeAll()
|
||||
pendingTaskIDs.removeAll()
|
||||
expiringTasks.removeAll()
|
||||
taskGenerations.removeAll()
|
||||
|
||||
@@ -248,26 +248,22 @@ final class GopeedLiveActivityManager: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
guard
|
||||
let data = payload.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data)
|
||||
as? [String: Any],
|
||||
let type = json["type"] as? String,
|
||||
let taskID = json["taskId"] as? String
|
||||
else {
|
||||
guard let event = GopeedTaskEvent.decode(payload) else {
|
||||
print("LiveActivity: invalid task event payload")
|
||||
return
|
||||
}
|
||||
|
||||
let taskID = event.taskID
|
||||
|
||||
guard !isTaskSuppressed(taskID) else {
|
||||
return
|
||||
}
|
||||
|
||||
let name = json["name"] as? String ?? "Download"
|
||||
let name = event.name
|
||||
|
||||
switch type {
|
||||
switch event.type {
|
||||
|
||||
case "task.start":
|
||||
case .start:
|
||||
let generation =
|
||||
startGeneration(
|
||||
taskID: taskID
|
||||
@@ -282,7 +278,7 @@ final class GopeedLiveActivityManager: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
case "task.progress":
|
||||
case .progress:
|
||||
guard
|
||||
let generation =
|
||||
generationForProgress(
|
||||
@@ -298,7 +294,7 @@ final class GopeedLiveActivityManager: NSObject {
|
||||
generation: generation
|
||||
)
|
||||
|
||||
case "task.pause":
|
||||
case .pause:
|
||||
if let generation =
|
||||
invalidateGeneration(
|
||||
taskID: taskID
|
||||
@@ -312,7 +308,7 @@ final class GopeedLiveActivityManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
case "task.done":
|
||||
case .done:
|
||||
if let generation =
|
||||
invalidateGeneration(
|
||||
taskID: taskID
|
||||
@@ -325,9 +321,9 @@ final class GopeedLiveActivityManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
case "task.error":
|
||||
case .error:
|
||||
let error =
|
||||
json["error"] as? String
|
||||
event.error
|
||||
?? "Download failed"
|
||||
|
||||
if let generation =
|
||||
@@ -343,7 +339,7 @@ final class GopeedLiveActivityManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
case "task.delete":
|
||||
case .delete:
|
||||
if let generation =
|
||||
invalidateGeneration(
|
||||
taskID: taskID
|
||||
@@ -357,7 +353,7 @@ final class GopeedLiveActivityManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
case .other:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 const _host = 'gopeed.com';
|
||||
final _storePages = <String, ({StoreExtensionPage page, DateTime expires})>{};
|
||||
final _storeRequests = <String, Future<StoreExtensionPage>>{};
|
||||
|
||||
Future<List<dynamic>> getReleases({int perPage = 10}) async {
|
||||
final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()});
|
||||
@@ -26,20 +28,52 @@ class GopeedSiteApi {
|
||||
StoreExtensionSort sort = StoreExtensionSort.stars,
|
||||
StoreSortOrder order = StoreSortOrder.desc,
|
||||
String? query,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final json = await _getJson(
|
||||
'/api/extensions',
|
||||
queryParameters: {
|
||||
'page': page.toString(),
|
||||
'limit': limit.clamp(1, 100).toString(),
|
||||
'sort': sort.name,
|
||||
'order': order.name,
|
||||
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
|
||||
},
|
||||
);
|
||||
final params = {
|
||||
'page': page.toString(),
|
||||
'limit': limit.clamp(1, 100).toString(),
|
||||
'sort': sort.name,
|
||||
'order': order.name,
|
||||
'view': 'summary',
|
||||
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>);
|
||||
}
|
||||
|
||||
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 {
|
||||
final uri = Uri.https(_host, '/api/extensions/install');
|
||||
await proxyRequest(
|
||||
@@ -49,8 +83,11 @@ class GopeedSiteApi {
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) async {
|
||||
final uri = Uri.https(_host, path, queryParameters);
|
||||
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) {
|
||||
return _getJsonUri(Uri.https(_host, path, queryParameters));
|
||||
}
|
||||
|
||||
Future<dynamic> _getJsonUri(Uri uri) async {
|
||||
final Response<String> response = await proxyRequest(uri.toString());
|
||||
if (response.data == null || response.data!.isEmpty) {
|
||||
throw Exception('Empty response from $uri');
|
||||
|
||||
@@ -17,6 +17,21 @@ class Options {
|
||||
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()
|
||||
class OptsExtraHttp {
|
||||
int connections;
|
||||
@@ -25,6 +40,7 @@ class OptsExtraHttp {
|
||||
bool? autoExtract;
|
||||
String archivePassword;
|
||||
bool deleteAfterExtract;
|
||||
ChecksumOption? checksum;
|
||||
|
||||
OptsExtraHttp({
|
||||
this.connections = 0,
|
||||
@@ -33,6 +49,7 @@ class OptsExtraHttp {
|
||||
this.autoExtract,
|
||||
this.archivePassword = '',
|
||||
this.deleteAfterExtract = false,
|
||||
this.checksum,
|
||||
});
|
||||
|
||||
factory OptsExtraHttp.fromJson(Map<String, dynamic> json) => _$OptsExtraHttpFromJson(json);
|
||||
|
||||
@@ -22,6 +22,16 @@ Map<String, dynamic> _$OptionsToJson(Options instance) => <String, dynamic>{
|
||||
'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(
|
||||
connections: (json['connections'] as num?)?.toInt() ?? 0,
|
||||
autoTorrent: json['autoTorrent'] as bool?,
|
||||
@@ -29,6 +39,9 @@ OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHtt
|
||||
autoExtract: json['autoExtract'] as bool?,
|
||||
archivePassword: json['archivePassword'] as String? ?? '',
|
||||
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>{
|
||||
@@ -38,4 +51,5 @@ Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, d
|
||||
'autoExtract': ?instance.autoExtract,
|
||||
'archivePassword': instance.archivePassword,
|
||||
'deleteAfterExtract': instance.deleteAfterExtract,
|
||||
'checksum': ?instance.checksum?.toJson(),
|
||||
};
|
||||
@@ -64,6 +64,7 @@ class StoreExtension {
|
||||
final List<String> topics;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final bool hasDetails;
|
||||
|
||||
StoreExtension({
|
||||
required this.id,
|
||||
@@ -84,6 +85,7 @@ class StoreExtension {
|
||||
required this.topics,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.hasDetails = true,
|
||||
});
|
||||
|
||||
factory StoreExtension.fromJson(Map<String, dynamic> json) {
|
||||
@@ -106,6 +108,7 @@ class StoreExtension {
|
||||
topics: _parseTopics(json['topics']),
|
||||
createdAt: _parseDate(json['createdAt']),
|
||||
updatedAt: _parseDate(json['updatedAt']),
|
||||
hasDetails: json.containsKey('readme'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class Task {
|
||||
Progress progress;
|
||||
DateTime createdAt;
|
||||
DateTime updatedAt;
|
||||
DateTime? doneAt;
|
||||
|
||||
Task({
|
||||
required this.id,
|
||||
@@ -45,6 +46,7 @@ class Task {
|
||||
required this.progress,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.doneAt,
|
||||
});
|
||||
|
||||
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>),
|
||||
createdAt: DateTime.parse(json['createdAt'] 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']);
|
||||
|
||||
Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{
|
||||
@@ -27,6 +28,7 @@ Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{
|
||||
'progress': instance.progress.toJson(),
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
'updatedAt': instance.updatedAt.toIso8601String(),
|
||||
'doneAt': ?instance.doneAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
const _$StatusEnumMap = {
|
||||
|
||||
@@ -3,13 +3,13 @@ import 'dart:collection';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../api/gopeed_site_api.dart';
|
||||
import '../../../api/model/extension.dart';
|
||||
import '../../../api/model/install_extension.dart';
|
||||
import '../../../api/model/store_extension.dart';
|
||||
import '../../../api/model/switch_extension.dart';
|
||||
import '../../../api/model/update_extension_settings.dart';
|
||||
import '../../../core/capabilities/app_capabilities.dart';
|
||||
import 'store_extension_details_provider.dart';
|
||||
|
||||
final extensionsControllerProvider = AsyncNotifierProvider<ExtensionsController, ExtensionsState>(
|
||||
ExtensionsController.new,
|
||||
@@ -121,63 +121,81 @@ class ExtensionsState {
|
||||
class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
||||
static const manualInstallBusyKey = '__manual_install__';
|
||||
int _devModeCount = 0;
|
||||
int _installedRevision = 0;
|
||||
int _storeRevision = 0;
|
||||
Future<void>? _updateCheck;
|
||||
bool _checkUpdatesAgain = false;
|
||||
|
||||
@override
|
||||
Future<ExtensionsState> build() async {
|
||||
var next = const ExtensionsState(loadingInstalled: true, loadingStore: true);
|
||||
Future<ExtensionsState> build() => _loadInitialData();
|
||||
|
||||
Future<ExtensionsState> _loadInitialData({bool forceRefresh = false}) async {
|
||||
final next = _current.copyWith(loadingInstalled: true, loadingStore: true);
|
||||
state = AsyncValue.data(next);
|
||||
final installed = await _loadInstalled(refreshUpdates: true, current: next);
|
||||
next = state.value ?? installed;
|
||||
await _refreshStore(current: next);
|
||||
return state.value ?? next;
|
||||
await Future.wait([
|
||||
_loadInstalled(refreshUpdates: true, current: next),
|
||||
_refreshStore(current: _current, forceRefresh: forceRefresh),
|
||||
]);
|
||||
return ref.mounted ? _current : next;
|
||||
}
|
||||
|
||||
Future<void> loadInitialData() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = AsyncValue.data(await build());
|
||||
await _loadInitialData(forceRefresh: true);
|
||||
}
|
||||
|
||||
Future<void> loadInstalled({bool refreshUpdates = false}) async {
|
||||
if (!ref.mounted) return;
|
||||
await _loadInstalled(refreshUpdates: refreshUpdates, current: _current.copyWith(loadingInstalled: true));
|
||||
}
|
||||
|
||||
Future<void> refreshStore() async {
|
||||
await _refreshStore(current: _current.copyWith(loadingStore: true));
|
||||
await _refreshStore(current: _current.copyWith(loadingStore: true), forceRefresh: true);
|
||||
}
|
||||
|
||||
Future<void> loadMoreStore() async {
|
||||
final current = _current;
|
||||
final pagination = current.storePagination;
|
||||
if (pagination == null || !pagination.hasNext || current.loadingMoreStore) return;
|
||||
final revision = _storeRevision;
|
||||
state = AsyncValue.data(current.copyWith(loadingMoreStore: true));
|
||||
try {
|
||||
final page = await GopeedSiteApi.instance.getExtensions(
|
||||
page: pagination.page + 1,
|
||||
limit: pagination.limit,
|
||||
sort: current.storeSort,
|
||||
query: current.storeQuery,
|
||||
);
|
||||
final page = await ref
|
||||
.read(gopeedSiteApiProvider)
|
||||
.getExtensions(
|
||||
page: pagination.page + 1,
|
||||
limit: pagination.limit,
|
||||
sort: current.storeSort,
|
||||
query: current.storeQuery,
|
||||
);
|
||||
if (!ref.mounted || revision != _storeRevision) return;
|
||||
state = AsyncValue.data(
|
||||
_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,
|
||||
loadingMoreStore: false,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
if (!ref.mounted || revision != _storeRevision) return;
|
||||
state = AsyncValue.data(_current.copyWith(loadingMoreStore: false));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> searchStore(String query) async {
|
||||
if (query.trim() == _current.storeQuery) return;
|
||||
state = AsyncValue.data(_current.copyWith(storeQuery: query.trim()));
|
||||
await refreshStore();
|
||||
await _refreshStore(current: _current);
|
||||
}
|
||||
|
||||
Future<void> changeSort(StoreExtensionSort sort) async {
|
||||
if (sort == _current.storeSort) return;
|
||||
state = AsyncValue.data(_current.copyWith(storeSort: sort));
|
||||
await refreshStore();
|
||||
await _refreshStore(current: _current);
|
||||
}
|
||||
|
||||
void changeFilter(ExtensionListFilter filter) {
|
||||
@@ -185,12 +203,11 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
||||
}
|
||||
|
||||
bool canUpdateItem(ExtensionListItem item) {
|
||||
final current = _current;
|
||||
if (item.installed == null) return false;
|
||||
if (current.listFilter == ExtensionListFilter.market && item.store != null) {
|
||||
return _compareVersion(item.store!.version, item.installed!.version) > 0;
|
||||
}
|
||||
return current.updateFlags.containsKey(item.installed!.identity);
|
||||
final installed = item.installed;
|
||||
if (installed == null) return false;
|
||||
// The backend checks GitHub live for every installed extension; the store
|
||||
// index may lag behind and must not decide update visibility.
|
||||
return _current.updateFlags.containsKey(installed.identity);
|
||||
}
|
||||
|
||||
Future<void> installFromStore(StoreExtension extension) async {
|
||||
@@ -240,6 +257,9 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
||||
await _runBusy(extension.identity, () async {
|
||||
await ref.read(gopeedServiceProvider).updateExtension(extension.identity);
|
||||
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());
|
||||
_bumpStoreInstallCount(extension.identity);
|
||||
_reportInstallSafe(extension.identity);
|
||||
@@ -255,23 +275,43 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> checkUpdate() async {
|
||||
final flags = <String, String>{};
|
||||
for (final ext in _current.installedExtensions) {
|
||||
try {
|
||||
final resp = await ref.read(gopeedServiceProvider).upgradeCheckExtension(ext.identity);
|
||||
if (resp.newVersion.isNotEmpty) {
|
||||
flags[ext.identity] = resp.newVersion;
|
||||
Future<void> checkUpdate() {
|
||||
if (!ref.mounted) return Future.value();
|
||||
_checkUpdatesAgain = true;
|
||||
// Installation/refresh can request a fresh scan while one is running.
|
||||
// Coalesce those requests instead of launching overlapping Git operations.
|
||||
return _updateCheck ??= _runUpdateChecks().whenComplete(() => _updateCheck = null);
|
||||
}
|
||||
|
||||
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() {
|
||||
if (_devModeCount == 0) {
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (_current.devMode) return;
|
||||
if (!ref.mounted || _current.devMode) return;
|
||||
_devModeCount = 0;
|
||||
});
|
||||
}
|
||||
@@ -284,26 +324,40 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
||||
ExtensionsState get _current => state.value ?? const ExtensionsState();
|
||||
|
||||
Future<ExtensionsState> _loadInstalled({required bool refreshUpdates, required ExtensionsState current}) async {
|
||||
final revision = ++_installedRevision;
|
||||
state = AsyncValue.data(current.copyWith(loadingInstalled: true));
|
||||
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));
|
||||
if (refreshUpdates) {
|
||||
await checkUpdate();
|
||||
unawaited(checkUpdate());
|
||||
}
|
||||
return _current;
|
||||
}
|
||||
|
||||
Future<void> _refreshStore({required ExtensionsState current}) async {
|
||||
state = AsyncValue.data(current.copyWith(loadingStore: true));
|
||||
final page = await GopeedSiteApi.instance.getExtensions(
|
||||
page: 1,
|
||||
limit: 20,
|
||||
sort: _current.storeSort,
|
||||
query: _current.storeQuery,
|
||||
);
|
||||
state = AsyncValue.data(
|
||||
_current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false),
|
||||
);
|
||||
Future<void> _refreshStore({required ExtensionsState current, bool forceRefresh = false}) async {
|
||||
final revision = ++_storeRevision;
|
||||
state = AsyncValue.data(current.copyWith(loadingStore: true, loadingMoreStore: false));
|
||||
try {
|
||||
final page = await ref
|
||||
.read(gopeedSiteApiProvider)
|
||||
.getExtensions(
|
||||
page: 1,
|
||||
limit: 20,
|
||||
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 {
|
||||
@@ -312,12 +366,15 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
|
||||
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
|
||||
if (ref.mounted) {
|
||||
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
|
||||
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _bumpStoreInstallCount(String id) {
|
||||
if (!ref.mounted) return;
|
||||
final current = _current;
|
||||
final index = current.storeExtensions.indexWhere((e) => e.id == id);
|
||||
if (index < 0) return;
|
||||
@@ -342,36 +399,19 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
||||
topics: ext.topics,
|
||||
createdAt: ext.createdAt,
|
||||
updatedAt: ext.updatedAt,
|
||||
hasDetails: ext.hasDetails,
|
||||
);
|
||||
state = AsyncValue.data(current.copyWith(storeExtensions: updated));
|
||||
}
|
||||
|
||||
void _reportInstallSafe(String id) {
|
||||
if (!ref.mounted) return;
|
||||
final siteApi = ref.read(gopeedSiteApiProvider);
|
||||
unawaited(() async {
|
||||
try {
|
||||
await GopeedSiteApi.instance.reportExtensionInstall(id);
|
||||
await siteApi.reportExtensionInstall(id);
|
||||
} 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 '../../application/extensions_controller.dart';
|
||||
import '../widgets/extension_detail_view.dart';
|
||||
import '../widgets/extension_update_dialog.dart';
|
||||
import '../widgets/extension_update_status.dart';
|
||||
|
||||
class ExtensionDetailsPage extends ConsumerWidget {
|
||||
const ExtensionDetailsPage({super.key, required this.extensionId, this.initialItem});
|
||||
@@ -24,6 +26,15 @@ class ExtensionDetailsPage extends ConsumerWidget {
|
||||
return AppDetailPage(
|
||||
title: item?.title ?? context.l10n.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
|
||||
? Center(
|
||||
child: stateAsync.isLoading
|
||||
@@ -33,4 +44,9 @@ class ExtensionDetailsPage extends ConsumerWidget {
|
||||
: 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_setting_field.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;
|
||||
|
||||
int _extensionGridColumnCount(double width) {
|
||||
@@ -485,25 +487,7 @@ class _Content extends StatelessWidget {
|
||||
}
|
||||
|
||||
bool _canUpdate(ExtensionsState state, ExtensionListItem item) {
|
||||
if (item.installed == null) return false;
|
||||
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;
|
||||
return item.installed != null && state.updateFlags.containsKey(item.installed!.identity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -994,11 +978,28 @@ class _ExtensionCard extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
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),
|
||||
Text(
|
||||
@@ -1067,13 +1068,6 @@ class _ExtensionCard extends ConsumerWidget {
|
||||
key: ValueKey('extension-card-actions-${item.id}'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (canUpdate && installed != null)
|
||||
shad.GhostButton(
|
||||
density: shad.ButtonDensity.icon,
|
||||
key: ValueKey('update-extension-${installed.identity}'),
|
||||
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
|
||||
child: const Icon(Icons.refresh),
|
||||
),
|
||||
if (installed == null && item.store != null)
|
||||
shad.GhostButton(
|
||||
key: ValueKey('install-store-extension-${item.store!.id}'),
|
||||
@@ -1127,21 +1121,17 @@ class _ExtensionCard extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
if (item.store == null) return card;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
key: ValueKey('open-extension-details-${item.id}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) {
|
||||
context.push('/extensions/${Uri.encodeComponent(item.id)}', extra: item);
|
||||
return;
|
||||
}
|
||||
onOpenDetails(item);
|
||||
},
|
||||
child: card,
|
||||
),
|
||||
return GestureDetector(
|
||||
key: ValueKey('open-extension-details-${item.id}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) {
|
||||
context.push('/extensions/${Uri.encodeComponent(item.id)}', extra: item);
|
||||
return;
|
||||
}
|
||||
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 '../../../../util/util.dart';
|
||||
import '../../application/extensions_controller.dart';
|
||||
import '../../application/store_extension_details_provider.dart';
|
||||
import 'extension_icon.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});
|
||||
|
||||
final ExtensionListItem? item;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@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(
|
||||
open: item != null,
|
||||
title: item?.title ?? '',
|
||||
onClose: onClose,
|
||||
drawerKey: const ValueKey('extension-details-drawer'),
|
||||
titleTrailing: updateChip,
|
||||
child: item == null ? const SizedBox.shrink() : ExtensionDetailView(item: item!),
|
||||
);
|
||||
}
|
||||
@@ -50,7 +64,13 @@ class ExtensionDetailView extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final palette = AppPalette.of(context);
|
||||
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 store = current.store;
|
||||
final busy = state?.busyExtensionIds.contains(current.id) ?? false;
|
||||
@@ -103,9 +123,7 @@ class ExtensionDetailView extends ConsumerWidget {
|
||||
'v${current.version}',
|
||||
style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (store != null) _ExtensionStats(store: store),
|
||||
if (installed != null && canUpdate)
|
||||
_ExtensionStatus(label: context.l10n.extensionCanUpdate, emphasized: true),
|
||||
if (summaryStore != null) _ExtensionStats(store: summaryStore),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -130,15 +148,6 @@ class ExtensionDetailView extends ConsumerWidget {
|
||||
_runAction(context, () => ref.read(extensionsControllerProvider.notifier).installFromStore(store)),
|
||||
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)
|
||||
AppLoadingButton(
|
||||
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)],
|
||||
],
|
||||
);
|
||||
@@ -204,6 +228,7 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> {
|
||||
final previous = oldWidget.item.installed;
|
||||
final current = widget.item.installed;
|
||||
if (previous?.identity != current?.identity ||
|
||||
oldWidget.item.id != widget.item.id ||
|
||||
previous?.devMode != current?.devMode ||
|
||||
previous?.devPath != current?.devPath ||
|
||||
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 {
|
||||
const _LinkButtonContent({required this.icon, required this.label});
|
||||
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ class _ExtensionUpdateDialogState extends ConsumerState<_ExtensionUpdateDialog>
|
||||
canPop: !_updating,
|
||||
child: AlertDialog(
|
||||
key: const ValueKey('extension-update-dialog'),
|
||||
title: Text(context.l10n.extensionCanUpdate),
|
||||
title: Text(context.l10n.newVersionAvailable),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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: [
|
||||
const Icon(Icons.open_in_new, size: 15),
|
||||
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),
|
||||
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());
|
||||
},
|
||||
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));
|
||||
},
|
||||
child: Text(dialogContext.l10n.confirm),
|
||||
child: Text(dialogContext.l10n.save),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -76,6 +76,7 @@ class TaskRecord {
|
||||
this.remaining,
|
||||
this.downloadDuration,
|
||||
this.createdAt,
|
||||
this.doneAt,
|
||||
this.progress,
|
||||
this.error,
|
||||
this.completedLabel,
|
||||
@@ -97,6 +98,7 @@ class TaskRecord {
|
||||
final String? remaining;
|
||||
final Duration? downloadDuration;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? doneAt;
|
||||
final double? progress;
|
||||
final String url;
|
||||
final String storagePath;
|
||||
@@ -145,6 +147,7 @@ class TaskRecord {
|
||||
remainingSeconds: _remainingSeconds(task, totalBytes, downloadedBytes),
|
||||
downloadDuration: task.progress.used > 0 ? Duration(microseconds: (task.progress.used + 999) ~/ 1000) : null,
|
||||
createdAt: task.createdAt,
|
||||
doneAt: task.doneAt,
|
||||
waiting: task.status == api_task.Status.wait,
|
||||
progress: progress,
|
||||
url: displayUrl,
|
||||
|
||||
@@ -66,6 +66,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
final _httpHeadersController = AppHttpHeadersController(defaultNames: const ['User-Agent', 'Cookie', 'Referer']);
|
||||
final _trackersController = TextEditingController();
|
||||
final _archivePasswordController = TextEditingController();
|
||||
final _checksumHashController = TextEditingController();
|
||||
final _formScrollController = ScrollController();
|
||||
|
||||
String _httpMethod = 'GET';
|
||||
@@ -74,6 +75,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
Map<String, String>? _initialLabels;
|
||||
RequestProxyMode _proxyMode = RequestProxyMode.follow;
|
||||
String _proxyScheme = 'http';
|
||||
String _checksumAlgorithm = 'sha256';
|
||||
bool _skipVerifyCert = false;
|
||||
bool? _autoTorrent;
|
||||
bool? _deleteTorrentAfterDownload;
|
||||
@@ -134,6 +136,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
_httpHeadersController.dispose();
|
||||
_trackersController.dispose();
|
||||
_archivePasswordController.dispose();
|
||||
_checksumHashController.dispose();
|
||||
_formScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -243,6 +246,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
_autoExtract = extra.autoExtract;
|
||||
_archivePasswordController.text = extra.archivePassword;
|
||||
_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 ...[
|
||||
AppFormRow(
|
||||
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 []}) {
|
||||
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(
|
||||
name: name ?? _renameController.text.trim(),
|
||||
path: _directoryController.text.trim(),
|
||||
@@ -1036,6 +1078,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
autoExtract: _autoExtract,
|
||||
archivePassword: _archivePasswordController.text,
|
||||
deleteAfterExtract: _deleteAfterExtract,
|
||||
checksum: checksum,
|
||||
).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),
|
||||
if (task.createdAt case final 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(
|
||||
key: const ValueKey('task-details-info-divider'),
|
||||
height: 1,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' show kDefaultDuration;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TaskProgressBar extends StatefulWidget {
|
||||
@@ -74,68 +75,76 @@ class _TaskProgressBarState extends State<TaskProgressBar> with SingleTickerProv
|
||||
color: widget.trackColor,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
if (widget.indeterminate) {
|
||||
final segmentWidth = constraints.maxWidth * 0.4;
|
||||
final left = lerpDouble(-segmentWidth, constraints.maxWidth, _controller.value);
|
||||
final progress = (widget.value ?? 0).clamp(0.0, 1.0);
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(begin: progress, end: progress),
|
||||
duration: MediaQuery.disableAnimationsOf(context) || widget.indeterminate
|
||||
? Duration.zero
|
||||
: 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(
|
||||
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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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": "إضافة",
|
||||
"edit": "تعديل",
|
||||
"newVersionTitle": "يتوفر إصدار جديد @version",
|
||||
"newVersionUpdate": "تحديث الآن",
|
||||
"newVersionUpdate": "تحديث",
|
||||
"newVersionLater": "لاحقاً",
|
||||
"extensions": "الإضافات",
|
||||
"extensionInstallUrl": "عنوان التثبيت",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "تحديث",
|
||||
"status": "الحالة",
|
||||
"createdAt": "وقت الإنشاء",
|
||||
"doneAt": "وقت الإكتمال",
|
||||
"speed": "السرعة",
|
||||
"uploadSpeed": "سرعة التشغيل",
|
||||
"remaining": "البقايا",
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
"add": "Əlavə et",
|
||||
"edit": "Redaktə et",
|
||||
"newVersionTitle": "Yeni versiya @version tapıldı",
|
||||
"newVersionUpdate": "İndi Yenilə",
|
||||
"newVersionUpdate": "Yenilə",
|
||||
"newVersionLater": "Sonra",
|
||||
"extensions": "Uzantılar",
|
||||
"extensionInstallUrl": "Quraşdırma URL-i",
|
||||
@@ -260,6 +260,7 @@
|
||||
"refresh": "Yenilə",
|
||||
"status": "Vəziyyət",
|
||||
"createdAt": "Yaradılıb",
|
||||
"doneAt": "Tamamlanıb",
|
||||
"speed": "Sürət",
|
||||
"uploadSpeed": "Yükləmə sürəti",
|
||||
"remaining": "Qalıb",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "Afegeix",
|
||||
"edit": "Edita",
|
||||
"newVersionTitle": "Descobriu la nova versió @version",
|
||||
"newVersionUpdate": "Actualitza ara",
|
||||
"newVersionUpdate": "Actualitza",
|
||||
"newVersionLater": "Més tard",
|
||||
"extensions": "Extensions",
|
||||
"extensionInstallUrl": "URL d'instal·lació",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "Refresca",
|
||||
"status": "Estat",
|
||||
"createdAt": "Data de creació",
|
||||
"doneAt": "Data de finalització",
|
||||
"speed": "Velocitat",
|
||||
"uploadSpeed": "Velocitat de pujada",
|
||||
"remaining": "Restant",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "Hinzugefügt",
|
||||
"edit": "Bearbeiten",
|
||||
"newVersionTitle": "Entdecken Sie die neue Version @version",
|
||||
"newVersionUpdate": "Jetzt aktualisieren",
|
||||
"newVersionUpdate": "Aktualisieren",
|
||||
"newVersionLater": "Später",
|
||||
"extensions": "Erweiterungen",
|
||||
"extensionInstallUrl": "Installations-URL",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "Aktualisieren",
|
||||
"status": "-Status",
|
||||
"createdAt": "Erstellungszeit",
|
||||
"doneAt": "Abgeschlossen am",
|
||||
"speed": "Geschwindigkeit",
|
||||
"uploadSpeed": "Upload-Geschwindigkeit",
|
||||
"remaining": "Verbleibend",
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
"add": "Add",
|
||||
"edit": "Edit",
|
||||
"newVersionTitle": "Discover new version @version",
|
||||
"newVersionUpdate": "Update Now",
|
||||
"newVersionUpdate": "Update",
|
||||
"newVersionLater": "Later",
|
||||
"extensions": "Extensions",
|
||||
"extensionInstallUrl": "Install URL",
|
||||
@@ -137,7 +137,7 @@
|
||||
"extensionLoadMore": "Load More",
|
||||
"extensionNoMore": "No more extensions",
|
||||
"extensionInstalled": "Installed",
|
||||
"extensionCanUpdate": "Update Available",
|
||||
"extensionCanUpdate": "Update",
|
||||
"extensionInstall": "Install",
|
||||
"extensionLoadLocal": "Install Local Folder",
|
||||
"extensionFilterAll": "All",
|
||||
@@ -268,6 +268,7 @@
|
||||
"refresh": "Refresh",
|
||||
"status": "Status",
|
||||
"createdAt": "Created",
|
||||
"doneAt": "Completed at",
|
||||
"speed": "Speed",
|
||||
"uploadSpeed": "Upload speed",
|
||||
"remaining": "Remaining",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "Añadir",
|
||||
"edit": "Editar",
|
||||
"newVersionTitle": "Nueva versión @version disponible",
|
||||
"newVersionUpdate": "Actualizar Ahora",
|
||||
"newVersionUpdate": "Actualizar",
|
||||
"newVersionLater": "Más tarde",
|
||||
"extensions": "Extensiones",
|
||||
"extensionInstallUrl": "URL de instalación",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "Actualizar",
|
||||
"status": "Situación",
|
||||
"createdAt": "Fecha de creación",
|
||||
"doneAt": "Fecha de finalización",
|
||||
"speed": "Velocidad",
|
||||
"uploadSpeed": "Velocidad de carga",
|
||||
"remaining": "Permaneciendo",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "افزودن",
|
||||
"edit": "ویرایش",
|
||||
"newVersionTitle": "@version عنوان: کشف نسخه جدید",
|
||||
"newVersionUpdate": "بروزرسانی",
|
||||
"newVersionUpdate": "بهروزرسانی",
|
||||
"newVersionLater": "بعداً",
|
||||
"extensions": "افزونهها",
|
||||
"extensionInstallUrl": "نشانی نصب",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "تازهسازی",
|
||||
"status": "وضعیت",
|
||||
"createdAt": "زمان ایجاد",
|
||||
"doneAt": "زمان تکمیل",
|
||||
"speed": "سرعت",
|
||||
"uploadSpeed": "سرعت آپلود",
|
||||
"remaining": "باقی مانده",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "Ajouter",
|
||||
"edit": "Modifier",
|
||||
"newVersionTitle": "Nouvelle version @version disponible",
|
||||
"newVersionUpdate": "Mettre à jour maintenant",
|
||||
"newVersionUpdate": "Mettre à jour",
|
||||
"newVersionLater": "Plus tard",
|
||||
"extensions": "Extensions",
|
||||
"extensionInstallUrl": "URL d'installation",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "Rafraîchir",
|
||||
"status": "État",
|
||||
"createdAt": "Date de création",
|
||||
"doneAt": "Terminé le",
|
||||
"speed": "Vitesse",
|
||||
"uploadSpeed": "Vitesse de chargement",
|
||||
"remaining": "Reste",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "Hozzáad",
|
||||
"edit": "Szerk",
|
||||
"newVersionTitle": "Új verzió felfedezése @version",
|
||||
"newVersionUpdate": "Frissítés most",
|
||||
"newVersionUpdate": "Frissítés",
|
||||
"newVersionLater": "Később",
|
||||
"extensions": "Bővítmények",
|
||||
"extensionInstallUrl": "Telepítési URL",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "Frissítés",
|
||||
"status": "állapot",
|
||||
"createdAt": "Létrehozás ideje",
|
||||
"doneAt": "Befejezés ideje",
|
||||
"speed": "sebesség",
|
||||
"uploadSpeed": "Feltöltési sebesség",
|
||||
"remaining": "Maradt",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "Tambah",
|
||||
"edit": "Sunting",
|
||||
"newVersionTitle": "Temukan versi batu @version",
|
||||
"newVersionUpdate": "Perbarui Sekarang",
|
||||
"newVersionUpdate": "Perbarui",
|
||||
"newVersionLater": "Nanti",
|
||||
"extensions": "Ekstensi",
|
||||
"extensionInstallUrl": "URL Instal",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "Segarkan",
|
||||
"status": "Status",
|
||||
"createdAt": "Waktu dibuat",
|
||||
"doneAt": "Waktu selesai",
|
||||
"speed": "Kecepatan",
|
||||
"uploadSpeed": "Kecepatan pemutakhiran",
|
||||
"remaining": "Sisa",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "Aggiungi",
|
||||
"edit": "Modifica",
|
||||
"newVersionTitle": "Scopri la nuova versione @version",
|
||||
"newVersionUpdate": "Aggiorna ora",
|
||||
"newVersionUpdate": "Aggiorna",
|
||||
"newVersionLater": "Dopo",
|
||||
"extensions": "Estensioni",
|
||||
"extensionInstallUrl": "Installa URL",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "Aggiorna",
|
||||
"status": "Stato",
|
||||
"createdAt": "Data di creazione",
|
||||
"doneAt": "Data di completamento",
|
||||
"speed": "Velocità",
|
||||
"uploadSpeed": "Velocità di carico",
|
||||
"remaining": "Rimanere",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "追加",
|
||||
"edit": "編集",
|
||||
"newVersionTitle": "新しいバージョン @version を発見する",
|
||||
"newVersionUpdate": "アップデート",
|
||||
"newVersionUpdate": "更新",
|
||||
"newVersionLater": "後で",
|
||||
"extensions": "拡張機能",
|
||||
"extensionInstallUrl": "インストールURL",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "リフレッシュ",
|
||||
"status": "ステータス",
|
||||
"createdAt": "作成日時",
|
||||
"doneAt": "完了日時",
|
||||
"speed": "スピード",
|
||||
"uploadSpeed": "アップロード速度",
|
||||
"remaining": "メンテナンス",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "추가",
|
||||
"edit": "편집",
|
||||
"newVersionTitle": "새 버전 @version 발견",
|
||||
"newVersionUpdate": "지금 업데이트",
|
||||
"newVersionUpdate": "업데이트",
|
||||
"newVersionLater": "나중에",
|
||||
"extensions": "확장 프로그램",
|
||||
"extensionInstallUrl": "설치 URL",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "의붓기",
|
||||
"status": "상태",
|
||||
"createdAt": "생성 시간",
|
||||
"doneAt": "완료 시간",
|
||||
"speed": "속도",
|
||||
"uploadSpeed": "연결 속도",
|
||||
"remaining": "의논하기",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "Dodaj",
|
||||
"edit": "Edycja",
|
||||
"newVersionTitle": "Sprawdź aktualizację @version",
|
||||
"newVersionUpdate": "Aktualizuj teraz",
|
||||
"newVersionUpdate": "Aktualizuj",
|
||||
"newVersionLater": "Później",
|
||||
"extensions": "Wtyczki",
|
||||
"extensionInstallUrl": "Instaluj z url",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "Odśwież",
|
||||
"status": "Stan",
|
||||
"createdAt": "Czas utworzenia",
|
||||
"doneAt": "Czas ukończenia",
|
||||
"speed": "Prędkość",
|
||||
"uploadSpeed": "Prędkość wysyłania",
|
||||
"remaining": "Pozostały",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "Adicionar",
|
||||
"edit": "Editar",
|
||||
"newVersionTitle": "Nova Versão Disponível @version",
|
||||
"newVersionUpdate": "Atualizar Agora",
|
||||
"newVersionUpdate": "Atualizar",
|
||||
"newVersionLater": "Mais Tarde",
|
||||
"extensions": "Extensões",
|
||||
"extensionInstallUrl": "Link de Instalação",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "Actualizar",
|
||||
"status": "Estado",
|
||||
"createdAt": "Data de criação",
|
||||
"doneAt": "Data de conclusão",
|
||||
"speed": "Velocidade",
|
||||
"uploadSpeed": "Velocidade de envio",
|
||||
"remaining": "Restante",
|
||||
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "освежиться",
|
||||
"status": "Статус",
|
||||
"createdAt": "Время создания",
|
||||
"doneAt": "Время завершения",
|
||||
"speed": "Скорость",
|
||||
"uploadSpeed": "Скорость загрузки",
|
||||
"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": "சேர்",
|
||||
"edit": "திருத்துக",
|
||||
"newVersionTitle": "புதிய பதிப்பைக் கண்டறியவும் @version",
|
||||
"newVersionUpdate": "இப்பொழுது புதுப்பி",
|
||||
"newVersionUpdate": "புதுப்பிப்பு",
|
||||
"newVersionLater": "பின்னர்",
|
||||
"extensions": "நீட்டிப்புகள்",
|
||||
"extensionInstallUrl": "நிறுவப்படும் URL",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "புதுப்பி",
|
||||
"status": "நிலை",
|
||||
"createdAt": "உருவாக்கிய நேரம்",
|
||||
"doneAt": "முடிந்த நேரம்",
|
||||
"speed": "வேகம்",
|
||||
"uploadSpeed": "வேகம்",
|
||||
"remaining": "மீதம்",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "Ekle",
|
||||
"edit": "Düzenle",
|
||||
"newVersionTitle": "Yeni sürüm @version keşfedildi",
|
||||
"newVersionUpdate": "Şimdi Güncelle",
|
||||
"newVersionUpdate": "Güncelle",
|
||||
"newVersionLater": "Daha Sonra",
|
||||
"extensions": "Eklentiler",
|
||||
"extensionInstallUrl": "Kurulum URL'si",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "Yenile",
|
||||
"status": "Durum durumu",
|
||||
"createdAt": "Oluşturulma zamanı",
|
||||
"doneAt": "Tamamlanma zamanı",
|
||||
"speed": "Hız",
|
||||
"uploadSpeed": "Yük hızı",
|
||||
"remaining": "Kalanlar",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "Додати",
|
||||
"edit": "Редагування",
|
||||
"newVersionTitle": "Доступна нова версія @Version",
|
||||
"newVersionUpdate": "Оновити зараз",
|
||||
"newVersionUpdate": "Оновити",
|
||||
"newVersionLater": "Пізніше",
|
||||
"extensions": "Розширення",
|
||||
"extensionInstallUrl": "URL для встановлення",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "Реверс",
|
||||
"status": "Контакты",
|
||||
"createdAt": "Час створення",
|
||||
"doneAt": "Час завершення",
|
||||
"speed": "ОДЯГ",
|
||||
"uploadSpeed": "Швидкість завантаження",
|
||||
"remaining": "Ременування",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "Thêm",
|
||||
"edit": "Sửa",
|
||||
"newVersionTitle": "Khám phá phiên bản mới @version",
|
||||
"newVersionUpdate": "Cập nhật ngay",
|
||||
"newVersionUpdate": "Cập nhật",
|
||||
"newVersionLater": "Sau",
|
||||
"extensions": "Tiện ích mở rộng",
|
||||
"extensionInstallUrl": "Liên kết cài đặt",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "Cập nhật",
|
||||
"status": "Trạng thái",
|
||||
"createdAt": "Thời gian tạo",
|
||||
"doneAt": "Thời gian hoàn thành",
|
||||
"speed": "Tốc độ",
|
||||
"uploadSpeed": "Tốc độ",
|
||||
"remaining": "Ở lại",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "添加",
|
||||
"edit": "编辑",
|
||||
"newVersionTitle": "发现新版本 @version",
|
||||
"newVersionUpdate": "立即更新",
|
||||
"newVersionUpdate": "更新",
|
||||
"newVersionLater": "稍后再说",
|
||||
"extensions": "扩展",
|
||||
"extensionInstallUrl": "安装链接",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "刷新",
|
||||
"status": "状态",
|
||||
"createdAt": "创建时间",
|
||||
"doneAt": "完成时间",
|
||||
"speed": "速度",
|
||||
"uploadSpeed": "上传速度",
|
||||
"remaining": "剩余时间",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "新增",
|
||||
"edit": "編輯",
|
||||
"newVersionTitle": "發現新版本 @version",
|
||||
"newVersionUpdate": "立即更新",
|
||||
"newVersionUpdate": "更新",
|
||||
"newVersionLater": "稍後",
|
||||
"extensions": "擴充功能",
|
||||
"extensionInstallUrl": "安裝 URL",
|
||||
@@ -246,6 +246,7 @@
|
||||
"refresh": "重新整理",
|
||||
"status": "狀態",
|
||||
"createdAt": "建立時間",
|
||||
"doneAt": "完成時間",
|
||||
"speed": "速度",
|
||||
"uploadSpeed": "上傳速度",
|
||||
"remaining": "剩餘時間",
|
||||
|
||||
@@ -5,7 +5,8 @@ class AppDesignTokens {
|
||||
|
||||
static const double windowHeaderHeight = 30;
|
||||
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 contentHeaderHeight = 48;
|
||||
static const double taskRowHeight = 72;
|
||||
|
||||
@@ -16,6 +16,7 @@ class AppDetailDrawer extends StatelessWidget {
|
||||
required this.child,
|
||||
this.drawerKey,
|
||||
this.width,
|
||||
this.titleTrailing,
|
||||
});
|
||||
|
||||
final bool open;
|
||||
@@ -24,6 +25,7 @@ class AppDetailDrawer extends StatelessWidget {
|
||||
final Widget child;
|
||||
final Key? drawerKey;
|
||||
final double? width;
|
||||
final Widget? titleTrailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -82,15 +84,25 @@ class AppDetailDrawer extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: palette.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: palette.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (titleTrailing != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Flexible(child: titleTrailing!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
@@ -13,12 +13,14 @@ class AppDetailPage extends StatelessWidget {
|
||||
required this.onBack,
|
||||
required this.child,
|
||||
this.backgroundColor,
|
||||
this.titleTrailing,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final VoidCallback onBack;
|
||||
final Widget child;
|
||||
final Color? backgroundColor;
|
||||
final Widget? titleTrailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -53,12 +55,22 @@ class AppDetailPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
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),
|
||||
|
||||
@@ -7,7 +7,8 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- device_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- file_picker (0.0.1):
|
||||
- file_picker_darwin (1.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- flutter_inappwebview_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
@@ -41,7 +42,7 @@ DEPENDENCIES:
|
||||
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/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`)
|
||||
- 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_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
@@ -68,8 +69,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos
|
||||
device_info_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
|
||||
file_picker:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
|
||||
file_picker_darwin:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/file_picker_darwin/darwin
|
||||
flutter_inappwebview_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos
|
||||
flutter_local_notifications:
|
||||
@@ -96,25 +97,25 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f
|
||||
desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43
|
||||
desktop_multi_window: 93667594ccc4b88d91a97972fd3b1b89667fa80a
|
||||
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
|
||||
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
|
||||
flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d
|
||||
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
|
||||
app_links: c3185399a5cabc2e610ee5ad52fb7269b84ff869
|
||||
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
|
||||
desktop_multi_window: 566489c048b501134f9d7fb6a2354c60a9126486
|
||||
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
|
||||
file_picker_darwin: 27e043831d68462d54f1c40e724b1f53c9d1e8be
|
||||
flutter_inappwebview_macos: bdf207b8f4ebd58e86ae06cd96b147de99a67c9b
|
||||
flutter_local_notifications: 14e285ca39907db50704f7f46c9ab7a526bd7ead
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
open_dir_macos: 79810d7921d777f8732bf6358b3a0839a8fad9b5
|
||||
open_dir_macos: 9649018f88e71eb9fb8aa3d95caec30a73c08c5b
|
||||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||
screen_retriever_macos: c5508cc3c66ff0d4db650480cf0ab691e220d933
|
||||
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
tray_manager: a104b5c81b578d83f3c3d0f40a997c8b10810166
|
||||
uri_content: 5f98ff12ded3f2e1681b689828d747bafb03ba5f
|
||||
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
||||
window_manager: b729e31d38fb04905235df9ea896128991cad99e
|
||||
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
|
||||
screen_retriever_macos: 038d3f7b34a779b06f536842003e41e8ec1fb4b7
|
||||
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
|
||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||
tray_manager: c2a678ee19e2a094f0c2d03660ee2c3e81cea8ab
|
||||
uri_content: e002074bcf6fbf905a3909603f9a2b1b490fce67
|
||||
url_launcher_macos: 175a54c831f4375a6cf895875f716ee5af3888ce
|
||||
window_manager: e25faf20d88283a0d46e7b1a759d07261ca27575
|
||||
|
||||
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', () {
|
||||
expect(
|
||||
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', () {
|
||||
|
||||
@@ -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';
|
||||
|
||||
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',
|
||||
'name': 'bundle',
|
||||
'protocol': 'bt',
|
||||
@@ -12,6 +12,7 @@ void main() {
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-01T00:00:00Z',
|
||||
'doneAt': doneAt,
|
||||
'meta': {
|
||||
'req': {'url': 'magnet:?xt=urn:btih:test'},
|
||||
'opts': {'path': '/downloads', 'selectFiles': selection},
|
||||
@@ -43,6 +44,13 @@ void main() {
|
||||
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', () {
|
||||
expect(
|
||||
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