Compare commits

..
Author SHA1 Message Date
Levi c03132d423 fix(windows): scope MSVC coroutine compatibility to affected plugins 2026-10-13 20:26:36 +08:00
Levi 128fada585 fix(ci): pin conforming Windows headers and keep desktop jobs independent 2026-10-13 20:24:47 +08:00
Levi 7dd704fc71 fix(ci): migrate Windows plugin forks to standard coroutines 2026-10-13 20:02:31 +08:00
Ashish Goswami 6f20790b17 feat(protocol/http, mcpserver, download): add checksum verification, MCP speed tool, pause webhook, and CLI flags (#1542) 2026-10-13 08:28:56 +08:00
Kunpeng Xie fa622a4063 fix(http): skip prefetch wait for restored downloads (#1584) 2026-10-12 11:13:42 +08:00
Levi c6619794fc fix: preserve Cookie headers in extension requests (#1581) 2026-10-10 22:10:08 +08:00
Levi 87a8f87b70 feat(ui): rework extension update detection and entry point (#1535) 2026-10-10 22:09:47 +08:00
WuFeng ba84a04a43 fix(settings): use Save as the primary label on settings form dialogs (#1571) 2026-10-08 16:31:16 +08:00
Levi 224b488087 feat(task): record and display task completion time (#1536) 2026-09-25 19:15:31 +08:00
Levi 7ce4fb7f0e fix(ui): smooth task progress bar updates (#1532) 2026-09-25 19:08:20 +08:00
UltraGeek0102 8d1ae93b03 refactor(ios): clean up continued processing task handling (#1547) 2026-09-25 16:21:09 +08:00
JiangJiang 363153fa33 fix(http): use Content-Range total size for 206 responses (#1545) 2026-09-23 09:52:59 +08:00
Levi f0dab90e09 feat(extension): expose request mutation methods on task.meta.req (#1538) 2026-09-23 09:33:28 +08:00
doctor-martin-sowa 6e60889ce8 feat(lang): Add Slovak localization (app_sk.arb) (#1541) 2026-09-23 09:32:07 +08:00
Levi 88d150b933 test(bt): isolate cleanup fixtures from part-file promotion (#1531) 2026-09-21 22:30:54 +08:00
Levi 694b43e3f9 docs: update beta download link to v2.0.0-beta.3 2026-09-19 08:16:57 +08:00
93 changed files with 4499 additions and 547 deletions

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+7 -2
View File
@@ -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
+158 -28
View File
@@ -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
View File
@@ -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)
}
}
-18
View File
@@ -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")
+1
View File
@@ -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
}
+26 -6
View File
@@ -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 {
+15 -60
View File
@@ -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
View File
@@ -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.
+25 -17
View File
@@ -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)
}
+17 -6
View File
@@ -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)
}
}
+104
View File
@@ -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)
}
})
}
}
}
+140
View File
@@ -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 })
}
+36 -3
View File
@@ -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) {
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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');
});
+1
View File
@@ -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
+43
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -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",
+103
View File
@@ -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"])
}
}
+74
View File
@@ -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
+162
View File
@@ -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
View File
@@ -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);
+14
View File
@@ -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(),
};
+2
View File
@@ -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);
+2
View File
@@ -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,
);
}
}
@@ -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);
@@ -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,
),
),
);
}
}
@@ -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,
),
),
),
],
);
},
},
),
);
},
),
+7 -1
View File
@@ -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": "البقايا",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+8 -2
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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": "باقی مانده",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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": "メンテナンス",
+7 -1
View File
@@ -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": "의논하기",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+6
View File
@@ -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": "оставаться",
+657
View File
@@ -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"
}
}
}
}
+7 -1
View File
@@ -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": "மீதம்",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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": "Ременування",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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": "剩余时间",
+7 -1
View File
@@ -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),
+22 -21
View File
@@ -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) {
+65 -4
View File
@@ -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,
);
}
+11
View File
@@ -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