mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-10 10:30:28 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c03132d423 | ||
|
|
128fada585 | ||
|
|
7dd704fc71 | ||
|
|
6f20790b17 | ||
|
|
fa622a4063 | ||
|
|
c6619794fc | ||
|
|
87a8f87b70 | ||
|
|
ba84a04a43 | ||
|
|
224b488087 | ||
|
|
7ce4fb7f0e | ||
|
|
8d1ae93b03 | ||
|
|
363153fa33 | ||
|
|
f0dab90e09 | ||
|
|
6e60889ce8 | ||
|
|
88d150b933 | ||
|
|
694b43e3f9 |
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()
|
||||
@@ -590,7 +620,7 @@ func (f *Fetcher) stopPrefetchAndCopyData() int64 {
|
||||
}
|
||||
|
||||
// Wait for prefetch to finish (with timeout)
|
||||
for i := 0; i < 1000 && !f.prefetchDone.Load(); i++ {
|
||||
for i := 0; f.prefetchStopCh != nil && i < 1000 && !f.prefetchDone.Load(); i++ {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
@@ -645,6 +675,22 @@ func (f *Fetcher) cleanupPrefetchFile() {
|
||||
f.resolveFallback.Store(false)
|
||||
}
|
||||
|
||||
// prepareFullRedownload resets all fetcher connection and prefetch state
|
||||
// after a checksum mismatch so the subsequent Start takes the fresh-start path.
|
||||
func (f *Fetcher) prepareFullRedownload() {
|
||||
f.connMu.Lock()
|
||||
if f.meta.Res.Range {
|
||||
f.connections = nil
|
||||
f.resolveConn = nil
|
||||
f.rangeReprobeEligible = false
|
||||
f.rangeValidatorPinned = false
|
||||
}
|
||||
f.connMu.Unlock()
|
||||
|
||||
f.cleanupPrefetchFile()
|
||||
f.resolveDataPos.Store(0)
|
||||
}
|
||||
|
||||
// Options may be replaced between Resolve and Start when creating a resolved task.
|
||||
func (f *Fetcher) initOptions() error {
|
||||
opts := f.meta.Opts
|
||||
@@ -662,6 +708,9 @@ func (f *Fetcher) initOptions() error {
|
||||
extra.Connections = 1
|
||||
}
|
||||
}
|
||||
if err := extra.Checksum.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -716,11 +765,26 @@ func (f *Fetcher) doStart() error {
|
||||
|
||||
// If retrying after error, reset connection states for retry
|
||||
if state == stateError {
|
||||
fullRedownload := f.checksumFailed.Swap(false)
|
||||
if fullRedownload {
|
||||
// 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.prepareFullRedownload()
|
||||
}
|
||||
f.connMu.Lock()
|
||||
for _, conn := range f.connections {
|
||||
// Reset connections that can be retried
|
||||
if !conn.Completed && conn.State != connCompleted {
|
||||
if !f.hasSequentialPrefixLocked(conn) {
|
||||
// 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 := fullRedownload || (!conn.Completed && conn.State != connCompleted)
|
||||
if shouldReset {
|
||||
if fullRedownload || !f.hasSequentialPrefixLocked(conn) {
|
||||
f.resetConnectionForRestart(conn)
|
||||
}
|
||||
conn.State = connNotStarted
|
||||
@@ -951,8 +1015,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
|
||||
}
|
||||
|
||||
@@ -1544,27 +1614,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 +1657,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 {
|
||||
@@ -2038,7 +2118,7 @@ func (f *Fetcher) resetConnectionForRestart(conn *connection) {
|
||||
// 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
|
||||
@@ -2209,10 +2289,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 +2306,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,388 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
gohttp "net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
)
|
||||
|
||||
// serveRangeContent serves byte range requests or full responses from data.
|
||||
func serveRangeContent(w gohttp.ResponseWriter, r *gohttp.Request, data []byte) {
|
||||
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||
rangeHdr := r.Header.Get(base.HttpHeaderRange)
|
||||
total := int64(len(data))
|
||||
if rangeHdr == "" {
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", total))
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse bytes=start-end or bytes=start-
|
||||
if !strings.HasPrefix(rangeHdr, "bytes=") {
|
||||
w.WriteHeader(gohttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
spec := strings.TrimPrefix(rangeHdr, "bytes=")
|
||||
parts := strings.Split(spec, "-")
|
||||
start, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || start < 0 || start >= total {
|
||||
w.WriteHeader(gohttp.StatusRequestedRangeNotSatisfiable)
|
||||
return
|
||||
}
|
||||
end := total - 1
|
||||
if len(parts) > 1 && parts[1] != "" {
|
||||
if parsedEnd, err := strconv.ParseInt(parts[1], 10, 64); err == nil && parsedEnd >= start && parsedEnd < total {
|
||||
end = parsedEnd
|
||||
}
|
||||
}
|
||||
|
||||
contentLength := end - start + 1
|
||||
w.Header().Set(base.HttpHeaderContentRange, fmt.Sprintf("bytes %d-%d/%d", start, end, total))
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", contentLength))
|
||||
w.WriteHeader(gohttp.StatusPartialContent)
|
||||
_, _ = w.Write(data[start : end+1])
|
||||
}
|
||||
|
||||
func TestFetcher_ChecksumMismatch_Retry(t *testing.T) {
|
||||
corruptContent := []byte("Corrupt Initial Download Data 33B")
|
||||
correctContent := []byte("Correct Retried Download Data 33B")
|
||||
|
||||
correctSha256Hasher := sha256.New()
|
||||
correctSha256Hasher.Write(correctContent)
|
||||
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
|
||||
|
||||
var phase atomic.Int32 // 0: serve corruptContent, 1: serve correctContent
|
||||
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
var served []byte
|
||||
if phase.Load() == 0 {
|
||||
served = corruptContent
|
||||
} else {
|
||||
served = correctContent
|
||||
}
|
||||
serveRangeContent(w, r, 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)
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
// Flip phase to 1 after observing checksum mismatch
|
||||
phase.Store(1)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_ChecksumMismatch_Retry_PartialPrefetch(t *testing.T) {
|
||||
corruptContent := []byte("Corrupt Initial Download Data 33B")
|
||||
correctContent := []byte("Correct Retried Download Data 33B")
|
||||
|
||||
correctSha256Hasher := sha256.New()
|
||||
correctSha256Hasher.Write(correctContent)
|
||||
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
|
||||
|
||||
var phase atomic.Int32 // 0: corrupt, 1: correct
|
||||
var isFirstRequest atomic.Bool
|
||||
isFirstRequest.Store(true)
|
||||
|
||||
releasePrefetch := make(chan struct{})
|
||||
var closeReleaseOnce sync.Once
|
||||
|
||||
var mu sync.Mutex
|
||||
var phase1Ranges []string
|
||||
|
||||
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
curPhase := phase.Load()
|
||||
if curPhase == 1 {
|
||||
mu.Lock()
|
||||
phase1Ranges = append(phase1Ranges, r.Header.Get(base.HttpHeaderRange))
|
||||
mu.Unlock()
|
||||
serveRangeContent(w, r, correctContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 0: First request is Resolve GET without Range header.
|
||||
// Write exactly 7 bytes and flush, then block until release channel closes or context is done.
|
||||
if isFirstRequest.CompareAndSwap(true, false) {
|
||||
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(corruptContent)))
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write(corruptContent[:7])
|
||||
if flusher, ok := w.(gohttp.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
select {
|
||||
case <-releasePrefetch:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Subsequent requests in Phase 0 (such as Range request from Start)
|
||||
serveRangeContent(w, r, corruptContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
defer closeReleaseOnce.Do(func() { close(releasePrefetch) })
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
fileName := "test_checksum_prefetch_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. Resolve phase
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/prefetch_retry.txt"}, opts); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
// Wait until prefetch has received exactly 7 bytes
|
||||
for i := 0; i < 200; i++ {
|
||||
if f.prefetchSize.Load() >= 7 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if f.prefetchSize.Load() < 7 {
|
||||
t.Fatalf("expected at least 7 prefetched bytes, got %d", f.prefetchSize.Load())
|
||||
}
|
||||
|
||||
// Release prefetch handler right before Start
|
||||
closeReleaseOnce.Do(func() { close(releasePrefetch) })
|
||||
|
||||
// 2. Start initial download attempt (will complete with corruptContent and fail checksum)
|
||||
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())
|
||||
}
|
||||
|
||||
// 3. Flip to phase 1 (correctContent) and retry
|
||||
phase.Store(1)
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
// Verify that in Phase 1 at least one Range request started at byte 0
|
||||
mu.Lock()
|
||||
rangesSeen := append([]string(nil), phase1Ranges...)
|
||||
mu.Unlock()
|
||||
|
||||
hasByteZero := false
|
||||
for _, r := range rangesSeen {
|
||||
if strings.HasPrefix(r, "bytes=0-") {
|
||||
hasByteZero = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasByteZero {
|
||||
t.Fatalf("expected at least one Phase 1 Range request starting at byte 0, saw: %v", rangesSeen)
|
||||
}
|
||||
|
||||
// Verify on-disk content matches correctContent exactly
|
||||
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))
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_ChecksumMismatch_Retry_NonRange(t *testing.T) {
|
||||
corruptContent := []byte("Corrupt Non-Range Initial Data 33B")
|
||||
correctContent := []byte("Correct Non-Range Retried Data 33B")
|
||||
|
||||
correctSha256Hasher := sha256.New()
|
||||
correctSha256Hasher.Write(correctContent)
|
||||
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
|
||||
|
||||
var phase atomic.Int32 // 0: corrupt, 1: correct
|
||||
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
// Server does not support Range (always returns full content 200 OK)
|
||||
var served []byte
|
||||
if phase.Load() == 0 {
|
||||
served = corruptContent
|
||||
} else {
|
||||
served = correctContent
|
||||
}
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(served)))
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write(served)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
fileName := "test_checksum_nonrange_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. Resolve and start initial attempt
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/nonrange_retry.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("expected checksum mismatch error on initial non-range 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. Switch phase and retry
|
||||
phase.Store(1)
|
||||
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error on non-range retry: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error on non-range retry (expected success): %v", err)
|
||||
}
|
||||
if f.getState() != stateDone {
|
||||
t.Fatalf("expected stateDone after successful non-range retry, got %v", f.getState())
|
||||
}
|
||||
|
||||
// 3. Verify on-disk file content
|
||||
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,304 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
gohttp "net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts)
|
||||
if err == nil {
|
||||
t.Fatal("Resolve 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)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid Checksum In Resolve Makes Zero Requests", func(t *testing.T) {
|
||||
var reqReceived atomic.Int32
|
||||
customServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
reqReceived.Add(1)
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write([]byte("some data"))
|
||||
}))
|
||||
defer customServer.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_invalid_checksum.txt",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: "invalid-hex-chars-not-valid-hex-00000000000000000000000000000000",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := f.Resolve(&base.Request{URL: customServer.URL + "/test.txt"}, opts)
|
||||
if err == nil {
|
||||
t.Fatal("Resolve expected error for invalid checksum hash, got nil")
|
||||
}
|
||||
if reqReceived.Load() != 0 {
|
||||
t.Fatalf("expected 0 requests to server on invalid checksum in Resolve, got %d", reqReceived.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
)
|
||||
|
||||
func TestFetcher_RestoredStartSkipsAbsentPrefetch(t *testing.T) {
|
||||
data := bytes.Repeat([]byte("0123456789abcdef"), 64*1024)
|
||||
var firstRequest atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
firstRequest.CompareAndSwap(0, time.Now().UnixNano())
|
||||
http.ServeContent(w, r, "restored.bin", time.Time{}, bytes.NewReader(data))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
size := int64(len(data))
|
||||
half := size / 2
|
||||
file := filepath.Join(dir, "restored.bin")
|
||||
if err := os.WriteFile(file, data[:half], 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn := &connection{ID: 0, Role: rolePrimary, State: connNotStarted, Chunk: newChunk(0, size-1), Downloaded: half}
|
||||
conn.Chunk.Downloaded = half
|
||||
rangeMode := true
|
||||
saved := &fetcherData{Connections: []*connection{conn}, Range: &rangeMode, ResourceSize: &size, FileSize: &size}
|
||||
meta := &fetcher.FetcherMeta{
|
||||
Req: &base.Request{URL: server.URL + "/restored.bin"},
|
||||
Res: &base.Resource{Size: size, Range: true, Files: []*base.FileInfo{{Name: "restored.bin", Size: size}}},
|
||||
Opts: &base.Options{Path: dir, Name: "restored.bin", Extra: &fhttp.OptsExtra{Connections: 1}},
|
||||
}
|
||||
fm := new(FetcherManager)
|
||||
_, restore := fm.Restore()
|
||||
f := restore(meta, saved).(*Fetcher)
|
||||
ctl := controller.NewController()
|
||||
ctl.GetConfig = func(v any) {
|
||||
if err := json.Unmarshal([]byte(test.ToJson(fm.DefaultConfig())), v); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
f.Setup(ctl)
|
||||
defer f.Close()
|
||||
started := time.Now()
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(actual, data) {
|
||||
t.Fatal("restored download differs from the complete server content")
|
||||
}
|
||||
if firstRequest.Load() == 0 {
|
||||
t.Fatal("restored download did not request the remaining content")
|
||||
}
|
||||
delay := time.Duration(firstRequest.Load() - started.UnixNano())
|
||||
t.Logf("first restored request after %s", delay)
|
||||
if delay > 2*time.Second {
|
||||
t.Errorf("restored download waited %s before its first request", delay)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,83 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ReqExtra struct {
|
||||
Method string `json:"method"`
|
||||
Header map[string]string `json:"header"`
|
||||
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
|
||||
}
|
||||
|
||||
// IsEmpty returns true when chk is nil, or when both Algorithm and Expected
|
||||
// are blank after trimming whitespace.
|
||||
func (chk *ChecksumOption) IsEmpty() bool {
|
||||
if chk == nil {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(chk.Algorithm) == "" && strings.TrimSpace(chk.Expected) == ""
|
||||
}
|
||||
|
||||
// NormalizedAlgorithm returns the canonical lower-cased algorithm ("md5", "sha1", "sha256")
|
||||
// and true if recognized, or ("", false) if unsupported.
|
||||
func (chk *ChecksumOption) NormalizedAlgorithm() (string, bool) {
|
||||
if chk == nil {
|
||||
return "", false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(chk.Algorithm)) {
|
||||
case "md5":
|
||||
return "md5", true
|
||||
case "sha1", "sha-1":
|
||||
return "sha1", true
|
||||
case "sha256", "sha-256":
|
||||
return "sha256", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks whether the checksum option is valid.
|
||||
// Empty options return nil (no verification).
|
||||
func (chk *ChecksumOption) Validate() error {
|
||||
if chk.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
algo, ok := chk.NormalizedAlgorithm()
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported checksum algorithm: %s", chk.Algorithm)
|
||||
}
|
||||
expected := strings.TrimSpace(chk.Expected)
|
||||
if expected == "" {
|
||||
return fmt.Errorf("checksum expected hash is required when algorithm is set")
|
||||
}
|
||||
var expectedLen int
|
||||
switch algo {
|
||||
case "md5":
|
||||
expectedLen = 32
|
||||
case "sha1":
|
||||
expectedLen = 40
|
||||
case "sha256":
|
||||
expectedLen = 64
|
||||
}
|
||||
if len(expected) != expectedLen {
|
||||
return fmt.Errorf("invalid checksum hash length for %s: expected %d characters, got %d", algo, expectedLen, len(expected))
|
||||
}
|
||||
if _, err := hex.DecodeString(expected); err != nil {
|
||||
return fmt.Errorf("invalid checksum hash for %s: expected valid hexadecimal string", algo)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 +93,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,162 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOptsExtra_Checksum(t *testing.T) {
|
||||
extra := &OptsExtra{
|
||||
Checksum: &ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
}
|
||||
if extra.Checksum.Algorithm != "sha256" || extra.Checksum.Expected != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
|
||||
t.Fatalf("unexpected checksum: %+v", extra.Checksum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecksumOption_Validate(t *testing.T) {
|
||||
validMd5 := "0123456789abcdef0123456789abcdef"
|
||||
validSha1 := "0123456789abcdef0123456789abcdef01234567"
|
||||
validSha256 := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
option *ChecksumOption
|
||||
wantErr bool
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "nil option",
|
||||
option: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty option (empty strings)",
|
||||
option: &ChecksumOption{Algorithm: "", Expected: ""},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty option (whitespace strings)",
|
||||
option: &ChecksumOption{Algorithm: " ", Expected: " "},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid md5 lowercase",
|
||||
option: &ChecksumOption{Algorithm: "md5", Expected: validMd5},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid md5 uppercase",
|
||||
option: &ChecksumOption{Algorithm: "MD5", Expected: strings.ToUpper(validMd5)},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid sha1 lowercase",
|
||||
option: &ChecksumOption{Algorithm: "sha1", Expected: validSha1},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid sha-1 alias",
|
||||
option: &ChecksumOption{Algorithm: "SHA-1", Expected: strings.ToUpper(validSha1)},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid sha256 lowercase",
|
||||
option: &ChecksumOption{Algorithm: "sha256", Expected: validSha256},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid sha-256 alias",
|
||||
option: &ChecksumOption{Algorithm: "sha-256", Expected: validSha256},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid sha256 with whitespace around hash and algorithm",
|
||||
option: &ChecksumOption{Algorithm: " sha256 ", Expected: " " + validSha256 + " "},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "unknown algorithm",
|
||||
option: &ChecksumOption{Algorithm: "crc32", Expected: "12345678"},
|
||||
wantErr: true,
|
||||
errSubstr: "unsupported checksum algorithm",
|
||||
},
|
||||
{
|
||||
name: "algorithm set but hash blank",
|
||||
option: &ChecksumOption{Algorithm: "sha256", Expected: " "},
|
||||
wantErr: true,
|
||||
errSubstr: "checksum expected hash is required",
|
||||
},
|
||||
{
|
||||
name: "sha256 with abc (short length)",
|
||||
option: &ChecksumOption{Algorithm: "sha256", Expected: "abc"},
|
||||
wantErr: true,
|
||||
errSubstr: "expected 64 characters, got 3",
|
||||
},
|
||||
{
|
||||
name: "sha256 with 64 x g (non-hex)",
|
||||
option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("g", 64)},
|
||||
wantErr: true,
|
||||
errSubstr: "expected valid hexadecimal string",
|
||||
},
|
||||
{
|
||||
name: "sha256 with 63 x a (off-by-one short)",
|
||||
option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("a", 63)},
|
||||
wantErr: true,
|
||||
errSubstr: "expected 64 characters, got 63",
|
||||
},
|
||||
{
|
||||
name: "sha256 with 65 x a (off-by-one long)",
|
||||
option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("a", 65)},
|
||||
wantErr: true,
|
||||
errSubstr: "expected 64 characters, got 65",
|
||||
},
|
||||
{
|
||||
name: "md5 with 31 chars",
|
||||
option: &ChecksumOption{Algorithm: "md5", Expected: strings.Repeat("a", 31)},
|
||||
wantErr: true,
|
||||
errSubstr: "expected 32 characters, got 31",
|
||||
},
|
||||
{
|
||||
name: "md5 with 33 chars",
|
||||
option: &ChecksumOption{Algorithm: "md5", Expected: strings.Repeat("a", 33)},
|
||||
wantErr: true,
|
||||
errSubstr: "expected 32 characters, got 33",
|
||||
},
|
||||
{
|
||||
name: "md5 with sha256-length hash (64 chars)",
|
||||
option: &ChecksumOption{Algorithm: "md5", Expected: validSha256},
|
||||
wantErr: true,
|
||||
errSubstr: "expected 32 characters, got 64",
|
||||
},
|
||||
{
|
||||
name: "sha1 with 39 chars",
|
||||
option: &ChecksumOption{Algorithm: "sha1", Expected: strings.Repeat("a", 39)},
|
||||
wantErr: true,
|
||||
errSubstr: "expected 40 characters, got 39",
|
||||
},
|
||||
{
|
||||
name: "sha1 with 41 chars",
|
||||
option: &ChecksumOption{Algorithm: "sha1", Expected: strings.Repeat("a", 41)},
|
||||
wantErr: true,
|
||||
errSubstr: "expected 40 characters, got 41",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.option.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr && tt.errSubstr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.errSubstr) {
|
||||
t.Fatalf("Validate() error = %v, want substring %q", err, tt.errSubstr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -185,12 +185,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 +239,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);
|
||||
@@ -354,24 +356,4 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
||||
}());
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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,6 +28,7 @@ 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;
|
||||
const _extensionGridSpacing = 10.0;
|
||||
@@ -485,25 +486,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 +977,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 +1067,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 +1120,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+63
-116
@@ -21,20 +21,33 @@ import '../../../../util/util.dart';
|
||||
import '../../application/extensions_controller.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!),
|
||||
);
|
||||
}
|
||||
@@ -104,8 +117,6 @@ class ExtensionDetailView extends ConsumerWidget {
|
||||
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),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -130,15 +141,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,7 +164,13 @@ class ExtensionDetailView extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (store != null) ...[SizedBox(height: mobile ? 24 : 28), _ExtensionReadme(item: current, mobile: mobile)],
|
||||
SizedBox(height: mobile ? 24 : 28),
|
||||
_ExtensionReadme(
|
||||
// Snapshot the version so upgrades reload the installed README.
|
||||
key: ValueKey((current.id, installed?.version, installed?.devMode, installed?.devPath, store?.readme)),
|
||||
item: current,
|
||||
mobile: mobile,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -180,7 +188,7 @@ class ExtensionDetailView extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _ExtensionReadme extends StatefulWidget {
|
||||
const _ExtensionReadme({required this.item, required this.mobile});
|
||||
const _ExtensionReadme({super.key, required this.item, required this.mobile});
|
||||
|
||||
final ExtensionListItem item;
|
||||
final bool mobile;
|
||||
@@ -190,7 +198,7 @@ class _ExtensionReadme extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ExtensionReadmeState extends State<_ExtensionReadme> {
|
||||
late Future<_ReadmeInfo> _readme;
|
||||
late final _ReadmeInfo _readme;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -198,109 +206,48 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> {
|
||||
_readme = _loadReadme(widget.item);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _ExtensionReadme oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final previous = oldWidget.item.installed;
|
||||
final current = widget.item.installed;
|
||||
if (previous?.identity != current?.identity ||
|
||||
previous?.devMode != current?.devMode ||
|
||||
previous?.devPath != current?.devPath ||
|
||||
oldWidget.item.store?.readme != widget.item.store?.readme) {
|
||||
_readme = _loadReadme(widget.item);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
return FutureBuilder<_ReadmeInfo>(
|
||||
future: _readme,
|
||||
builder: (context, snapshot) {
|
||||
final info = snapshot.data;
|
||||
if (info == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
if (info.content.trim().isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
key: const ValueKey('extension-details-readme'),
|
||||
padding: EdgeInsets.only(top: widget.mobile ? 32 : 36),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'README',
|
||||
style: TextStyle(
|
||||
color: palette.textMuted,
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
MarkdownBody(
|
||||
data: info.content,
|
||||
selectable: true,
|
||||
styleSheet: _markdownStyle(palette, mobile: widget.mobile),
|
||||
onTapLink: (_, href, _) {
|
||||
final resolved = _resolveReadmeUrl(widget.item, href, info: info, forImage: false);
|
||||
if (resolved != null) unawaited(_openUrl(resolved));
|
||||
},
|
||||
imageBuilder: (uri, title, alt) {
|
||||
final resolved = _resolveReadmeUrl(widget.item, uri.toString(), info: info, forImage: true);
|
||||
if (resolved == null) return const SizedBox.shrink();
|
||||
final image = resolved.startsWith('file:')
|
||||
? Image.file(
|
||||
File(Uri.parse(resolved).toFilePath()),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
)
|
||||
: Image.network(
|
||||
resolved,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: ClipRRect(borderRadius: BorderRadius.circular(6), child: image),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
final info = _readme;
|
||||
if (info.content.trim().isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
key: const ValueKey('extension-details-readme'),
|
||||
padding: EdgeInsets.only(top: widget.mobile ? 32 : 36),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'README',
|
||||
style: TextStyle(color: palette.textMuted, fontSize: 10.5, fontWeight: FontWeight.w800, letterSpacing: 1.2),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
MarkdownBody(
|
||||
data: info.content,
|
||||
selectable: true,
|
||||
styleSheet: _markdownStyle(palette, mobile: widget.mobile),
|
||||
onTapLink: (_, href, _) {
|
||||
final resolved = _resolveReadmeUrl(widget.item, href, info: info, forImage: false);
|
||||
if (resolved != null) unawaited(_openUrl(resolved));
|
||||
},
|
||||
imageBuilder: (uri, title, alt) {
|
||||
final resolved = _resolveReadmeUrl(widget.item, uri.toString(), info: info, forImage: true);
|
||||
if (resolved == null) return const SizedBox.shrink();
|
||||
final image = resolved.startsWith('file:')
|
||||
? Image.file(
|
||||
File(Uri.parse(resolved).toFilePath()),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
)
|
||||
: Image.network(resolved, fit: BoxFit.contain, errorBuilder: (_, _, _) => const SizedBox.shrink());
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: ClipRRect(borderRadius: BorderRadius.circular(6), child: image),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -407,7 +354,7 @@ MarkdownStyleSheet _markdownStyle(AppPalette palette, {required bool mobile}) {
|
||||
);
|
||||
}
|
||||
|
||||
Future<_ReadmeInfo> _loadReadme(ExtensionListItem item) async {
|
||||
_ReadmeInfo _loadReadme(ExtensionListItem item) {
|
||||
final installed = item.installed;
|
||||
final remote = item.store?.readme ?? '';
|
||||
if (installed == null || Util.isWeb()) return _ReadmeInfo(content: remote);
|
||||
|
||||
+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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+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 = 'md5';
|
||||
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();
|
||||
}
|
||||
@@ -184,6 +187,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) {
|
||||
_fileDataUri = '';
|
||||
}
|
||||
if (_inputUrls().length > 1) {
|
||||
_checksumHashController.clear();
|
||||
_checksumAlgorithm = 'md5';
|
||||
}
|
||||
}
|
||||
_recognizeMagnetUri(urlText.trim());
|
||||
}
|
||||
@@ -193,6 +200,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
final req = task.req;
|
||||
final opts = task.opts;
|
||||
setState(() {
|
||||
_checksumAlgorithm = 'md5';
|
||||
_checksumHashController.clear();
|
||||
if (req?.url.isNotEmpty == true) {
|
||||
_fileDataUri = '';
|
||||
_renameController.clear();
|
||||
@@ -243,6 +252,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 +683,34 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_inputUrls().length <= 1) ...[
|
||||
const SizedBox(height: 14),
|
||||
AppFormPair(
|
||||
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||
firstLabel: context.l10n.checksumAlgorithm,
|
||||
secondLabel: context.l10n.checksumHash,
|
||||
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: context.l10n.checksumExpectedHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
] else ...[
|
||||
AppFormRow(
|
||||
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||
@@ -950,6 +993,25 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
}
|
||||
|
||||
bool _validateAdvancedOptions() {
|
||||
if (_protocolTab == 0 && _inputUrls().length <= 1) {
|
||||
final checksumExpected = _checksumHashController.text.trim();
|
||||
if (checksumExpected.isNotEmpty) {
|
||||
final expectedLen = switch (_checksumAlgorithm.toLowerCase()) {
|
||||
'md5' => 32,
|
||||
'sha1' || 'sha-1' => 40,
|
||||
'sha256' || 'sha-256' => 64,
|
||||
_ => 0,
|
||||
};
|
||||
if (expectedLen > 0 && checksumExpected.length != expectedLen) {
|
||||
_showToast(context.l10n.checksumInvalidLength(_checksumAlgorithm.toUpperCase(), expectedLen.toString()));
|
||||
return false;
|
||||
}
|
||||
if (!RegExp(r'^[0-9a-fA-F]+$').hasMatch(checksumExpected)) {
|
||||
_showToast(context.l10n.checksumInvalidHex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_proxyMode != RequestProxyMode.custom) {
|
||||
return true;
|
||||
}
|
||||
@@ -1022,8 +1084,15 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
return text.length == 40 && RegExp(r'^[0-9a-fA-F]+$').hasMatch(text);
|
||||
}
|
||||
|
||||
api_options.Options _buildOptions({String? name, List<int> selectFiles = const []}) {
|
||||
api_options.Options _buildOptions({String? name, List<int> selectFiles = const [], bool omitChecksum = false}) {
|
||||
final connections = int.tryParse(_connectionsController.text.trim()) ?? 0;
|
||||
final checksumExpected = _checksumHashController.text.trim();
|
||||
final checksum = (!omitChecksum && _inputUrls().length <= 1 && 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 +1105,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
autoExtract: _autoExtract,
|
||||
archivePassword: _archivePasswordController.text,
|
||||
deleteAfterExtract: _deleteAfterExtract,
|
||||
checksum: checksum,
|
||||
).toJson(),
|
||||
);
|
||||
}
|
||||
@@ -1050,13 +1120,14 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
Future<void> _submitResolved(Request request, ResolveResult result, List<int> selectedIndexes) async {
|
||||
final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList())
|
||||
..sort();
|
||||
final isMultiFile = selected.length > 1;
|
||||
if (result.id.isNotEmpty) {
|
||||
await ref
|
||||
.read(gopeedServiceProvider)
|
||||
.createTask(
|
||||
CreateTask(
|
||||
rid: result.id,
|
||||
opts: _buildOptions(selectFiles: selected),
|
||||
opts: _buildOptions(selectFiles: selected, omitChecksum: isMultiFile),
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -1070,11 +1141,11 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
opts: api_options.Options(
|
||||
name: file.name,
|
||||
path: path.join(_directoryController.text.trim(), result.res.name, file.path),
|
||||
extra: _buildOptions().extra,
|
||||
extra: _buildOptions(omitChecksum: isMultiFile).extra,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions()));
|
||||
await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions(omitChecksum: isMultiFile)));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "عنوان التثبيت",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "فك الضغط تلقائياً",
|
||||
"archivePassword": "كلمة مرور الملف المضغوط",
|
||||
"archivePasswordHint": "اتركه فارغاً إذا لم يكن محمياً بكلمة مرور",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "حذف الملف المضغوط بعد فك الضغط",
|
||||
"extracting": "جاري فك الضغط",
|
||||
"extractDone": "تم فك الضغط بنجاح",
|
||||
@@ -246,6 +251,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",
|
||||
@@ -171,6 +171,11 @@
|
||||
"autoExtract": "Arxivləri Avtomatik Çıxar",
|
||||
"archivePassword": "Arxiv Parolu",
|
||||
"archivePasswordHint": "Parolla qorunmayıbsa boş buraxın",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Çıxarıldıqdan sonra arxivi sil",
|
||||
"extracting": "Çıxarılır",
|
||||
"extractDone": "Çıxarma tamamlandı",
|
||||
@@ -260,6 +265,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ó",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Extreu els comprimits automàticament",
|
||||
"archivePassword": "Contrasenya del comprimit",
|
||||
"archivePasswordHint": "Deixeu-lo en blanc si no està protegit amb contrasenya",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Suprimeix el comprimit després de l'extracció",
|
||||
"extracting": "S'està extraient",
|
||||
"extractDone": "Extracció completada",
|
||||
@@ -246,6 +251,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",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Archive automatisch entpacken",
|
||||
"archivePassword": "Archiv-Passwort",
|
||||
"archivePasswordHint": "Leer lassen, wenn nicht passwortgeschützt",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Archiv nach Entpacken löschen",
|
||||
"extracting": "Wird entpackt",
|
||||
"extractDone": "Entpacken abgeschlossen",
|
||||
@@ -246,6 +251,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",
|
||||
@@ -178,6 +178,11 @@
|
||||
"autoExtract": "Extract Archives Automatically",
|
||||
"archivePassword": "Archive Password",
|
||||
"archivePasswordHint": "Leave empty if not password protected",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Delete Archives After Extraction",
|
||||
"extracting": "Extracting",
|
||||
"extractDone": "Extraction completed",
|
||||
@@ -268,6 +273,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",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Extraer archivos automáticamente",
|
||||
"archivePassword": "Contraseña del archivo",
|
||||
"archivePasswordHint": "Dejar vacío si no tiene contraseña",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Eliminar archivo después de extraer",
|
||||
"extracting": "Extrayendo",
|
||||
"extractDone": "Extracción completada",
|
||||
@@ -246,6 +251,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": "نشانی نصب",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "استخراج خودکار بایگانیها",
|
||||
"archivePassword": "رمز عبور بایگانی",
|
||||
"archivePasswordHint": "اگر رمز ندارد خالی بگذارید",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "حذف آرشیو پس از استخراج",
|
||||
"extracting": "در حال استخراج",
|
||||
"extractDone": "استخراج کامل شد",
|
||||
@@ -246,6 +251,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",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Extraire automatiquement les archives",
|
||||
"archivePassword": "Mot de passe de l'archive",
|
||||
"archivePasswordHint": "Laisser vide si non protégé par mot de passe",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Supprimer l'archive après extraction",
|
||||
"extracting": "Extraction en cours",
|
||||
"extractDone": "Extraction terminée",
|
||||
@@ -246,6 +251,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",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Archívumok automatikus kicsomagolása",
|
||||
"archivePassword": "Archívum jelszó",
|
||||
"archivePasswordHint": "Hagyja üresen, ha nincs jelszóval védve",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Archívum törlése kicsomagolás után",
|
||||
"extracting": "Kicsomagolás",
|
||||
"extractDone": "Kicsomagolás befejezve",
|
||||
@@ -246,6 +251,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",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Ekstrak Arsip Otomatis",
|
||||
"archivePassword": "Kata Sandi Arsip",
|
||||
"archivePasswordHint": "Kosongkan jika tidak dilindungi kata sandi",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Hapus arsip setelah ekstraksi",
|
||||
"extracting": "Mengekstrak",
|
||||
"extractDone": "Ekstraksi selesai",
|
||||
@@ -246,6 +251,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",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Estrai automaticamente gli archivi",
|
||||
"archivePassword": "Password archivio",
|
||||
"archivePasswordHint": "Lasciare vuoto se non protetto da password",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Elimina l'archivio dopo l'estrazione",
|
||||
"extracting": "Estrazione in corso",
|
||||
"extractDone": "Estrazione completata",
|
||||
@@ -246,6 +251,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",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "アーカイブを自動展開",
|
||||
"archivePassword": "アーカイブパスワード",
|
||||
"archivePasswordHint": "パスワードなしの場合は空のままにしてください",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "展開後にアーカイブを削除",
|
||||
"extracting": "展開中",
|
||||
"extractDone": "展開完了",
|
||||
@@ -246,6 +251,7 @@
|
||||
"refresh": "リフレッシュ",
|
||||
"status": "ステータス",
|
||||
"createdAt": "作成日時",
|
||||
"doneAt": "完了日時",
|
||||
"speed": "スピード",
|
||||
"uploadSpeed": "アップロード速度",
|
||||
"remaining": "メンテナンス",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "추가",
|
||||
"edit": "편집",
|
||||
"newVersionTitle": "새 버전 @version 발견",
|
||||
"newVersionUpdate": "지금 업데이트",
|
||||
"newVersionUpdate": "업데이트",
|
||||
"newVersionLater": "나중에",
|
||||
"extensions": "확장 프로그램",
|
||||
"extensionInstallUrl": "설치 URL",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "압축 파일 자동 압축 해제",
|
||||
"archivePassword": "압축 파일 비밀번호",
|
||||
"archivePasswordHint": "비밀번호가 없으면 비워 두세요",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "압축 해제 후 압축 파일 삭제",
|
||||
"extracting": "압축 해제 중",
|
||||
"extractDone": "압축 해제 완료",
|
||||
@@ -246,6 +251,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",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Automatycznie wyodrębnij archiwa",
|
||||
"archivePassword": "Hasło archiwum",
|
||||
"archivePasswordHint": "Pozostaw puste, jeśli nie ma hasła",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Usuń archiwum po rozpakowaniu",
|
||||
"extracting": "Rozpakowywanie",
|
||||
"extractDone": "Rozpakowywanie zakończone",
|
||||
@@ -246,6 +251,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",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Extrair arquivos automaticamente",
|
||||
"archivePassword": "Senha do arquivo",
|
||||
"archivePasswordHint": "Deixar vazio se não estiver protegido por senha",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Excluir arquivo após extração",
|
||||
"extracting": "Extraindo",
|
||||
"extractDone": "Extração concluída",
|
||||
@@ -246,6 +251,7 @@
|
||||
"refresh": "Actualizar",
|
||||
"status": "Estado",
|
||||
"createdAt": "Data de criação",
|
||||
"doneAt": "Data de conclusão",
|
||||
"speed": "Velocidade",
|
||||
"uploadSpeed": "Velocidade de envio",
|
||||
"remaining": "Restante",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Автоматически распаковать архивы",
|
||||
"archivePassword": "Пароль архива",
|
||||
"archivePasswordHint": "Оставить пустым, если архив не защищён паролем",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Удалить архив после распаковки",
|
||||
"extracting": "Распаковка",
|
||||
"extractDone": "Распаковка завершена",
|
||||
@@ -246,6 +251,7 @@
|
||||
"refresh": "освежиться",
|
||||
"status": "Статус",
|
||||
"createdAt": "Время создания",
|
||||
"doneAt": "Время завершения",
|
||||
"speed": "Скорость",
|
||||
"uploadSpeed": "Скорость загрузки",
|
||||
"remaining": "оставаться",
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
{
|
||||
"@@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",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"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",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "காப்பகங்களை தானாக பிரித்தெடு",
|
||||
"archivePassword": "காப்பக கடவுச்சொல்",
|
||||
"archivePasswordHint": "கடவுச்சொல் இல்லையென்றால் காலியாக விடவும்",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "பிரித்தெடுத்த பிறகு காப்பகத்தை நீக்கு",
|
||||
"extracting": "பிரித்தெடுக்கிறது",
|
||||
"extractDone": "பிரித்தெடுத்தல் முடிந்தது",
|
||||
@@ -246,6 +251,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",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Arşivleri Otomatik Çıkar",
|
||||
"archivePassword": "Arşiv Şifresi",
|
||||
"archivePasswordHint": "Şifre korumalı değilse boş bırakın",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Çıkardıktan sonra arşivi sil",
|
||||
"extracting": "Çıkarılıyor",
|
||||
"extractDone": "Çıkarma tamamlandı",
|
||||
@@ -246,6 +251,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 для встановлення",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Автоматично розпаковувати архіви",
|
||||
"archivePassword": "Пароль архіву",
|
||||
"archivePasswordHint": "Залишити порожнім, якщо архів не захищений паролем",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Видалити архів після розпакування",
|
||||
"extracting": "Розпакування",
|
||||
"extractDone": "Розпакування завершено",
|
||||
@@ -246,6 +251,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",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "Tự động giải nén tập tin nén",
|
||||
"archivePassword": "Mật khẩu tập tin nén",
|
||||
"archivePasswordHint": "Để trống nếu không có mật khẩu",
|
||||
"checksumAlgorithm": "Checksum Algorithm",
|
||||
"checksumHash": "Checksum Hash",
|
||||
"checksumExpectedHint": "Expected hash (optional)",
|
||||
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||
"deleteAfterExtract": "Xóa tập tin nén sau khi giải nén",
|
||||
"extracting": "Đang giải nén",
|
||||
"extractDone": "Giải nén hoàn tất",
|
||||
@@ -246,6 +251,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": "安装链接",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "自动解压压缩包",
|
||||
"archivePassword": "压缩包密码",
|
||||
"archivePasswordHint": "如无密码请留空",
|
||||
"checksumAlgorithm": "校验和算法",
|
||||
"checksumHash": "校验和",
|
||||
"checksumExpectedHint": "预期哈希值(可选)",
|
||||
"checksumInvalidLength": "{algorithm} 校验和哈希长度无效:应为 {length} 个字符",
|
||||
"checksumInvalidHex": "校验和哈希无效:应为十六进制字符(0-9,a-f)",
|
||||
"deleteAfterExtract": "解压后删除压缩包",
|
||||
"extracting": "正在解压",
|
||||
"extractDone": "解压完成",
|
||||
@@ -246,6 +251,7 @@
|
||||
"refresh": "刷新",
|
||||
"status": "状态",
|
||||
"createdAt": "创建时间",
|
||||
"doneAt": "完成时间",
|
||||
"speed": "速度",
|
||||
"uploadSpeed": "上传速度",
|
||||
"remaining": "剩余时间",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"add": "新增",
|
||||
"edit": "編輯",
|
||||
"newVersionTitle": "發現新版本 @version",
|
||||
"newVersionUpdate": "立即更新",
|
||||
"newVersionUpdate": "更新",
|
||||
"newVersionLater": "稍後",
|
||||
"extensions": "擴充功能",
|
||||
"extensionInstallUrl": "安裝 URL",
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "自動解壓縮壓縮包",
|
||||
"archivePassword": "壓縮包密碼",
|
||||
"archivePasswordHint": "如無密碼請留空",
|
||||
"checksumAlgorithm": "校驗和演算法",
|
||||
"checksumHash": "校驗和",
|
||||
"checksumExpectedHint": "預期雜湊值(可選)",
|
||||
"checksumInvalidLength": "{algorithm} 校驗和雜湊長度無效:應為 {length} 個字元",
|
||||
"checksumInvalidHex": "校驗和雜湊無效:應為十六進位字元(0-9,a-f)",
|
||||
"deleteAfterExtract": "解壓後刪除壓縮檔",
|
||||
"extracting": "正在解壓",
|
||||
"extractDone": "解壓完成",
|
||||
@@ -246,6 +251,7 @@
|
||||
"refresh": "重新整理",
|
||||
"status": "狀態",
|
||||
"createdAt": "建立時間",
|
||||
"doneAt": "完成時間",
|
||||
"speed": "速度",
|
||||
"uploadSpeed": "上傳速度",
|
||||
"remaining": "剩餘時間",
|
||||
|
||||
@@ -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,162 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/extension.dart';
|
||||
import 'package:gopeed/api/model/store_extension.dart';
|
||||
import 'package:gopeed/features/extensions/application/extensions_controller.dart';
|
||||
import 'package:gopeed/features/extensions/presentation/pages/extension_details_page.dart';
|
||||
import 'package:gopeed/features/extensions/presentation/widgets/extension_detail_view.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
for (final mobile in [false, true]) {
|
||||
testWidgets('local README renders while the market request is pending (mobile: $mobile)', (tester) async {
|
||||
final directory = Directory.systemTemp.createTempSync('gopeed-readme-');
|
||||
addTearDown(() => directory.deleteSync(recursive: true));
|
||||
File(path.join(directory.path, 'README.md')).writeAsStringSync('# Local README');
|
||||
final installed = _installed(directory.path);
|
||||
final controller = _ReadmeController(
|
||||
ExtensionsState(installedExtensions: [installed], loadingStore: true),
|
||||
pendingMarket: true,
|
||||
);
|
||||
await _pumpDetails(tester, controller, ExtensionListItem(installed: installed), mobile: mobile);
|
||||
|
||||
expect(controller.marketRequest.isCompleted, isFalse);
|
||||
expect(find.text('Local README'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
// A stale market response must not replace the installed documentation.
|
||||
controller.finishMarket();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Local README'), findsOneWidget);
|
||||
expect(find.text('Old market README'), findsNothing);
|
||||
});
|
||||
|
||||
for (final mutate in [false, true]) {
|
||||
testWidgets('open README reloads after an upgrade (mobile: $mobile, mutate: $mutate)', (tester) async {
|
||||
final directory = Directory.systemTemp.createTempSync('gopeed-readme-');
|
||||
addTearDown(() => directory.deleteSync(recursive: true));
|
||||
final readme = File(path.join(directory.path, 'README.md'))..writeAsStringSync('# Before upgrade');
|
||||
final installed = _installed(directory.path);
|
||||
final controller = _ReadmeController(
|
||||
ExtensionsState(installedExtensions: [installed], storeExtensions: [_store()]),
|
||||
);
|
||||
await _pumpDetails(
|
||||
tester,
|
||||
controller,
|
||||
ExtensionListItem(installed: installed, store: _store()),
|
||||
mobile: mobile,
|
||||
);
|
||||
expect(find.text('Before upgrade'), findsOneWidget);
|
||||
|
||||
readme.writeAsStringSync('# After upgrade');
|
||||
controller.publishVersion('2.0.0', mutate: mutate);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('After upgrade'), findsOneWidget);
|
||||
expect(find.text('Before upgrade'), findsNothing);
|
||||
expect(find.text('Old market README'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('missing local README falls back to the store copy', (tester) async {
|
||||
final directory = Directory.systemTemp.createTempSync('gopeed-readme-');
|
||||
addTearDown(() => directory.deleteSync(recursive: true));
|
||||
final installed = _installed(directory.path);
|
||||
final controller = _ReadmeController(
|
||||
ExtensionsState(installedExtensions: [installed], storeExtensions: [_store()]),
|
||||
);
|
||||
await _pumpDetails(tester, controller, ExtensionListItem(installed: installed, store: _store()), mobile: false);
|
||||
expect(find.text('Old market README'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpDetails(
|
||||
WidgetTester tester,
|
||||
_ReadmeController controller,
|
||||
ExtensionListItem item, {
|
||||
required bool mobile,
|
||||
}) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = Size(mobile ? 390 : 1100, 900);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [extensionsControllerProvider.overrideWith(() => controller)],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: mobile
|
||||
? ExtensionDetailsPage(extensionId: item.id, initialItem: item)
|
||||
: ExtensionDetailDrawer(item: item, onClose: () {}),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
class _ReadmeController extends ExtensionsController {
|
||||
_ReadmeController(this.initial, {this.pendingMarket = false});
|
||||
|
||||
final ExtensionsState initial;
|
||||
final bool pendingMarket;
|
||||
final marketRequest = Completer<ExtensionsState>();
|
||||
|
||||
@override
|
||||
Future<ExtensionsState> build() async {
|
||||
if (!pendingMarket) return initial;
|
||||
state = AsyncValue.data(initial);
|
||||
return marketRequest.future;
|
||||
}
|
||||
|
||||
void finishMarket() {
|
||||
marketRequest.complete(state.requireValue.copyWith(storeExtensions: [_store()], loadingStore: false));
|
||||
}
|
||||
|
||||
void publishVersion(String version, {required bool mutate}) {
|
||||
final current = state.requireValue;
|
||||
final installed = current.installedExtensions.single;
|
||||
final updated = mutate ? installed : Extension.fromJson(installed.toJson());
|
||||
updated.version = version;
|
||||
state = AsyncValue.data(current.copyWith(installedExtensions: [updated]));
|
||||
}
|
||||
}
|
||||
|
||||
Extension _installed(String directory) => Extension(
|
||||
identity: 'gopeed@test',
|
||||
name: 'test',
|
||||
author: 'Gopeed',
|
||||
title: 'Test extension',
|
||||
description: 'Installed extension',
|
||||
icon: '',
|
||||
version: '1.0.0',
|
||||
homepage: '',
|
||||
repository: null,
|
||||
disabled: false,
|
||||
devMode: true,
|
||||
devPath: directory,
|
||||
);
|
||||
|
||||
StoreExtension _store() => StoreExtension(
|
||||
id: 'gopeed@test',
|
||||
repoFullName: 'GopeedLab/test',
|
||||
repoUrl: 'https://github.com/GopeedLab/test',
|
||||
name: 'test',
|
||||
author: 'Gopeed',
|
||||
title: 'Test extension',
|
||||
description: 'Store extension',
|
||||
version: '1.0.0',
|
||||
readme: '# Old market README',
|
||||
installCount: 1,
|
||||
stars: 1,
|
||||
topics: const [],
|
||||
);
|
||||
@@ -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'),
|
||||
|
||||
@@ -26,6 +26,7 @@ import 'package:gopeed/shared/theme/app_design_tokens.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/shared/widgets/app_loading_button.dart';
|
||||
import 'package:gopeed/shared/widgets/app_tooltip.dart';
|
||||
import 'package:gopeed/l10n/app_localizations.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
@@ -611,6 +612,355 @@ void main() {
|
||||
expect(find.byKey(const ValueKey('create-history-clear')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('create-history-close')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('checksum state is reset for each incoming task and not inherited (T1)', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
tester.view.physicalSize = const Size(700, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
CreateTask? submitted;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
submitted = task;
|
||||
return 'task-id';
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final pending = container.read(pendingCreateTaskProvider.notifier);
|
||||
// Task A: with checksum
|
||||
pending.set(
|
||||
CreateTask(
|
||||
req: Request(url: 'https://example.com/taskA.zip'),
|
||||
opts: Options(
|
||||
extra: OptsExtraHttp(
|
||||
checksum: ChecksumOption(
|
||||
algorithm: 'sha256',
|
||||
expected: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||
),
|
||||
).toJson(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/create',
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
|
||||
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
|
||||
],
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: shad.ShadcnApp.router(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
routerConfig: router,
|
||||
builder: (_, child) => AppComponentThemes(child: child!),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Expand advanced
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_fieldText(tester, 'create-task-checksum-expected'), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
||||
|
||||
// Task B form 1: null opts
|
||||
pending.set(CreateTask(req: Request(url: 'https://example.com/taskB1.zip')));
|
||||
router.go('/create');
|
||||
await tester.pumpAndSettle();
|
||||
expect(_fieldText(tester, 'create-task-checksum-expected'), '');
|
||||
|
||||
// Task B form 2: empty Options
|
||||
pending.set(CreateTask(req: Request(url: 'https://example.com/taskB2.zip'), opts: Options()));
|
||||
router.go('/create');
|
||||
await tester.pumpAndSettle();
|
||||
expect(_fieldText(tester, 'create-task-checksum-expected'), '');
|
||||
|
||||
// Task B form 3: HTTP extra with no checksum
|
||||
pending.set(
|
||||
CreateTask(
|
||||
req: Request(url: 'https://example.com/taskB3.zip'),
|
||||
opts: Options(extra: OptsExtraHttp(connections: 8).toJson()),
|
||||
),
|
||||
);
|
||||
router.go('/create');
|
||||
await tester.pumpAndSettle();
|
||||
expect(_fieldText(tester, 'create-task-checksum-expected'), '');
|
||||
|
||||
// Submit task B3 and ensure no checksum is sent
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-toggle')));
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(submitted?.opts?.extra, isNotNull);
|
||||
final extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
|
||||
expect(extra.checksum, isNull);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('checksum input validation blocks submission on invalid input (T3-UI)', (tester) async {
|
||||
tester.view.physicalSize = const Size(1024, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
CreateTask? submitted;
|
||||
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
submitted = task;
|
||||
return 'task-id';
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final pending = container.read(pendingCreateTaskProvider.notifier);
|
||||
pending.set(CreateTask(req: Request(url: 'https://example.com/file.zip')));
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/create',
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
|
||||
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
|
||||
],
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: shad.ShadcnApp.router(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
routerConfig: router,
|
||||
builder: (_, child) => AppComponentThemes(child: child!),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Expand advanced options
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Test 1: "abc" for SHA-256
|
||||
await tester.tap(find.text('SHA-256'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'abc');
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNull);
|
||||
|
||||
// Test 2: 64 x "g" (non-hex) for SHA-256
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'g' * 64);
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNull);
|
||||
|
||||
// Test 3: 63 x "a" (invalid length) for SHA-256
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'a' * 63);
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNull);
|
||||
|
||||
// Test 4: 65 x "a" (invalid length) for SHA-256
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'a' * 65);
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNull);
|
||||
|
||||
// Test 5: MD5 with sha256-length hash
|
||||
await tester.tap(find.text('MD5'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'a' * 64);
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNull);
|
||||
|
||||
// Test 6: Valid MD5 in UPPER case
|
||||
submitted = null;
|
||||
await tester.tap(find.text('MD5'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), '0123456789ABCDEF0123456789ABCDEF');
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNotNull);
|
||||
var extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
|
||||
expect(extra.checksum?.algorithm, 'md5');
|
||||
expect(extra.checksum?.expected, '0123456789ABCDEF0123456789ABCDEF');
|
||||
|
||||
// Test 7: Valid SHA-1 in lower case
|
||||
submitted = null;
|
||||
pending.set(CreateTask(req: Request(url: 'https://example.com/file2.zip')));
|
||||
router.go('/create');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('SHA-1'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNotNull);
|
||||
extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
|
||||
expect(extra.checksum?.algorithm, 'sha1');
|
||||
expect(extra.checksum?.expected, 'da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||
|
||||
// Test 8: Valid SHA-256 in lower case
|
||||
submitted = null;
|
||||
pending.set(CreateTask(req: Request(url: 'https://example.com/file3.zip')));
|
||||
router.go('/create');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('SHA-256'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNotNull);
|
||||
extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
|
||||
expect(extra.checksum?.algorithm, 'sha256');
|
||||
expect(extra.checksum?.expected, 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
||||
|
||||
// Test 9: Omitted checksum (empty hash)
|
||||
submitted = null;
|
||||
pending.set(CreateTask(req: Request(url: 'https://example.com/file4.zip')));
|
||||
router.go('/create');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), ' ');
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, isNotNull);
|
||||
extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
|
||||
expect(extra.checksum, isNull);
|
||||
});
|
||||
|
||||
testWidgets('multi-URL and multi-file batch creation omits checksum (T4)', (tester) async {
|
||||
tester.view.physicalSize = const Size(1024, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final createdTasks = <CreateTask>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
createdTasks.add(task);
|
||||
return 'task-id';
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/create',
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
|
||||
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
|
||||
],
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: shad.ShadcnApp.router(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
routerConfig: router,
|
||||
builder: (_, child) => AppComponentThemes(child: child!),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Enter 2 URLs
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('create-task-url-input')),
|
||||
'https://example.com/file1.zip\nhttps://example.com/file2.zip',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Advanced options should hide the checksum field
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('create-task-checksum-expected')), findsNothing);
|
||||
|
||||
// Confirm creation
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(createdTasks.length, 2);
|
||||
for (final t in createdTasks) {
|
||||
final extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(t.opts!.extra! as Map));
|
||||
expect(extra.checksum, isNull);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('checksum UI uses localized labels (T5)', (tester) async {
|
||||
tester.view.physicalSize = const Size(700, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
child: shad.ShadcnApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(
|
||||
child: CreateTaskWindowPage(
|
||||
initialTask: CreateTask(req: Request(url: 'https://example.com/file.zip')),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Checksum Algorithm'), findsOneWidget);
|
||||
expect(find.text('Checksum Hash'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
String _fieldText(WidgetTester tester, String key) {
|
||||
|
||||
@@ -50,6 +50,7 @@ import 'package:gopeed/features/home/presentation/widgets/tasks_top_bar.dart';
|
||||
import 'package:gopeed/features/extensions/application/extensions_controller.dart';
|
||||
import 'package:gopeed/features/extensions/presentation/pages/extension_details_page.dart';
|
||||
import 'package:gopeed/features/extensions/presentation/pages/extensions_page.dart';
|
||||
import 'package:gopeed/features/extensions/presentation/widgets/extension_icon.dart';
|
||||
import 'package:gopeed/features/home/presentation/widgets/primary_rail.dart';
|
||||
import 'package:gopeed/features/tasks/application/pending_update_task.dart';
|
||||
import 'package:gopeed/features/tasks/application/task_batch_selection_controller.dart';
|
||||
@@ -688,6 +689,30 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final width in [320.0, 1100.0]) {
|
||||
testWidgets('extension update status keeps the card header at icon height at $width', (tester) async {
|
||||
await _setTestSize(tester, Size(width, 900));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [extensionsControllerProvider.overrideWith(UpdateableExtensionsController.new)],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: const ExtensionsPage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final card = find.byKey(const ValueKey('extension-card-extension-0'));
|
||||
final icon = find.descendant(of: card, matching: find.byType(ExtensionIcon));
|
||||
final description = find.descendant(of: card, matching: find.text('Installed extension'));
|
||||
expect(find.byKey(const ValueKey('extension-card-update-status-extension-0')), findsOneWidget);
|
||||
expect(tester.getRect(description).top - tester.getRect(icon).bottom, closeTo(10, 0.01));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('extension details reuse the desktop detail drawer', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(1100, 900));
|
||||
final controller = FakeExtensionsController();
|
||||
@@ -817,7 +842,9 @@ void main() {
|
||||
await tester.tap(find.byKey(const ValueKey('extension-card-extension-0')));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
final entry = find.byKey(ValueKey(details ? 'extension-details-update' : 'update-extension-extension-0'));
|
||||
final entry = find.byKey(
|
||||
ValueKey(details ? 'extension-details-update' : 'extension-card-update-status-extension-0'),
|
||||
);
|
||||
final dialog = find.byKey(const ValueKey('extension-update-dialog'));
|
||||
final confirm = find.byKey(const ValueKey('confirm-update-extension-button'));
|
||||
final cancel = find.byKey(const ValueKey('cancel-update-extension-button'));
|
||||
@@ -2327,7 +2354,7 @@ void main() {
|
||||
final progress = find.descendant(of: updateButton, matching: find.byType(shad.CircularProgressIndicator));
|
||||
expect(progress, findsOneWidget);
|
||||
expect(tester.getSize(progress), const Size.square(14));
|
||||
expect(find.descendant(of: updateButton, matching: find.text('Update Now')), findsOneWidget);
|
||||
expect(find.descendant(of: updateButton, matching: find.text('Update')), findsOneWidget);
|
||||
|
||||
runtimeController.completeTrackerUpdate();
|
||||
await tester.pumpAndSettle();
|
||||
@@ -2480,7 +2507,7 @@ void main() {
|
||||
expect(find.text('%date% — Full date (YYYY-MM-DD)'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('path-placeholder-%date%')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('归档'), findsOneWidget);
|
||||
@@ -2649,7 +2676,7 @@ void main() {
|
||||
await tester.tap(find.byKey(const ValueKey('settings-language-select')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('English'), findsOneWidget);
|
||||
expect(SettingsLanguageSelect.supportedValues, hasLength(21));
|
||||
expect(SettingsLanguageSelect.supportedValues, hasLength(22));
|
||||
expect(SettingsLanguageSelect.supportedValues, containsAll(<String>['zh', 'zh_TW', 'pt']));
|
||||
expect(SettingsLanguageSelect.supportedValues, isNot(contains('zh_CN')));
|
||||
expect(SettingsLanguageSelect.supportedValues, isNot(contains('pt_BR')));
|
||||
@@ -4127,6 +4154,7 @@ void main() {
|
||||
remaining: '1 minute remaining',
|
||||
downloadDuration: const Duration(hours: 1, minutes: 2, seconds: 3),
|
||||
createdAt: DateTime(2026, 8, 27, 12, 34, 56),
|
||||
doneAt: DateTime(2026, 8, 28, 1, 2, 3),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
@@ -4197,7 +4225,14 @@ void main() {
|
||||
expect(find.text('50 B / 100 B'), findsNothing);
|
||||
expect(find.text('Created'), findsOneWidget);
|
||||
expect(find.text('2026-08-27 12:34:56'), findsOneWidget);
|
||||
expect(find.text('Completed at'), findsOneWidget);
|
||||
expect(find.text('2026-08-28 01:02:03'), findsOneWidget);
|
||||
expect(
|
||||
tester.getBottomLeft(find.text('2026-08-27 12:34:56')).dy,
|
||||
lessThan(tester.getTopLeft(find.text('Completed at')).dy),
|
||||
);
|
||||
final infoDivider = find.byKey(const ValueKey('task-details-info-divider'));
|
||||
expect(tester.getBottomLeft(find.text('Completed at')).dy, lessThan(tester.getTopLeft(infoDivider).dy));
|
||||
expect(tester.getBottomLeft(find.text('Created')).dy, lessThan(tester.getTopLeft(infoDivider).dy));
|
||||
expect(tester.getBottomLeft(infoDivider).dy, lessThan(tester.getTopLeft(find.text('Download Link')).dy));
|
||||
expect(find.text('1 minute remaining'), findsNothing);
|
||||
@@ -4259,6 +4294,30 @@ void main() {
|
||||
expect(find.text('—'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('completed task details show a dash when no done time is recorded', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(900, 700));
|
||||
final task = _taskRecord(
|
||||
id: 'done-details',
|
||||
name: 'done.zip',
|
||||
status: TaskStatus.completed,
|
||||
downloadDuration: const Duration(hours: 1, minutes: 2, seconds: 3),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: TaskDetailsView(task: task, mobile: false, onOpenStorage: () {}, onUpdateUrl: (_) async {}),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Completed at'), findsOneWidget);
|
||||
expect(find.text('—'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('failed task details omit the unavailable error reason', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(900, 700));
|
||||
final task = TaskRecord(
|
||||
@@ -6164,6 +6223,7 @@ TaskRecord _taskRecord({
|
||||
String? remaining,
|
||||
Duration? downloadDuration,
|
||||
DateTime? createdAt,
|
||||
DateTime? doneAt,
|
||||
bool isFolder = false,
|
||||
Map<String, String> requestHeaders = const {},
|
||||
}) {
|
||||
@@ -6184,6 +6244,7 @@ TaskRecord _taskRecord({
|
||||
remaining: remaining,
|
||||
downloadDuration: downloadDuration,
|
||||
createdAt: createdAt,
|
||||
doneAt: doneAt,
|
||||
requestHeaders: requestHeaders,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,17 @@ add_subdirectory("runner")
|
||||
# them to the application.
|
||||
include(flutter/generated_plugins.cmake)
|
||||
|
||||
# Temporary compatibility for legacy coroutine headers on MSVC 14.51.
|
||||
# Remove once both Windows plugins use standard coroutines.
|
||||
if(MSVC)
|
||||
foreach(plugin IN ITEMS flutter_inappwebview_windows_plugin permission_handler_windows_plugin)
|
||||
if(TARGET ${plugin})
|
||||
target_compile_definitions(${plugin} PRIVATE
|
||||
_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS)
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
|
||||
# === Installation ===
|
||||
# Support files are copied into place next to the executable, so that it can
|
||||
|
||||
Reference in new issue
Block a user