Compare commits

...
60 changed files with 2794 additions and 333 deletions

No files matched your search

+7 -3
View File
@@ -7,9 +7,11 @@ import (
) )
type args struct { type args struct {
url string url string
connections *int connections *int
dir *string dir *string
checksum *string
checksumAlgo *string
} }
func parse() *args { func parse() *args {
@@ -20,6 +22,8 @@ func parse() *args {
var args args var args args
args.connections = flag.Int("C", 16, "Concurrent connections.") args.connections = flag.Int("C", 16, "Concurrent connections.")
args.dir = flag.String("D", dir, "Store directory.") args.dir = flag.String("D", dir, "Store directory.")
args.checksum = flag.String("checksum", "", "Expected checksum hash (hex encoded).")
args.checksumAlgo = flag.String("checksum-algo", "md5", "Checksum algorithm: md5, sha1, or sha256.")
flag.Parse() flag.Parse()
t := flag.Args() t := flag.Args()
if len(t) > 0 { if len(t) > 0 {
+17 -2
View File
@@ -19,6 +19,18 @@ func main() {
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(1) wg.Add(1)
var checksumOpt *http.ChecksumOption
if args.checksum != nil && *args.checksum != "" {
algo := "md5"
if args.checksumAlgo != nil && *args.checksumAlgo != "" {
algo = *args.checksumAlgo
}
checksumOpt = &http.ChecksumOption{
Algorithm: algo,
Expected: *args.checksum,
}
}
_, err := download.Boot(). _, err := download.Boot().
URL(args.url). URL(args.url).
Listener(func(event *download.Event) { Listener(func(event *download.Event) {
@@ -43,8 +55,11 @@ func main() {
} }
}). }).
Create(&base.Options{ Create(&base.Options{
Path: *args.dir, Path: *args.dir,
Extra: http.OptsExtra{Connections: *args.connections}, Extra: http.OptsExtra{
Connections: *args.connections,
Checksum: checksumOpt,
},
}) })
if err != nil { if err != nil {
panic(err) panic(err)
+111 -10
View File
@@ -2,8 +2,13 @@ package http
import ( import (
"context" "context"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"hash"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@@ -280,6 +285,12 @@ type Fetcher struct {
primaryReadyOnce sync.Once primaryReadyOnce sync.Once
primaryReadyCh chan struct{} primaryReadyCh chan struct{}
// checksumFailed marks that the last completed download failed
// checksum verification, so the next retry must force a full
// re-download instead of skipping connections that already
// reported Completed.
checksumFailed atomic.Bool
// Start pending mechanism // Start pending mechanism
startPending atomic.Bool startPending atomic.Bool
resolvedCh chan struct{} // Signal when resolve completes resolvedCh chan struct{} // Signal when resolve completes
@@ -609,7 +620,7 @@ func (f *Fetcher) stopPrefetchAndCopyData() int64 {
} }
// Wait for prefetch to finish (with timeout) // 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) time.Sleep(10 * time.Millisecond)
} }
@@ -664,6 +675,22 @@ func (f *Fetcher) cleanupPrefetchFile() {
f.resolveFallback.Store(false) 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. // Options may be replaced between Resolve and Start when creating a resolved task.
func (f *Fetcher) initOptions() error { func (f *Fetcher) initOptions() error {
opts := f.meta.Opts opts := f.meta.Opts
@@ -681,6 +708,9 @@ func (f *Fetcher) initOptions() error {
extra.Connections = 1 extra.Connections = 1
} }
} }
if err := extra.Checksum.Validate(); err != nil {
return err
}
return nil return nil
} }
@@ -735,11 +765,26 @@ func (f *Fetcher) doStart() error {
// If retrying after error, reset connection states for retry // If retrying after error, reset connection states for retry
if state == stateError { if state == stateError {
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() f.connMu.Lock()
for _, conn := range f.connections { for _, conn := range f.connections {
// Reset connections that can be retried // On a normal error, only reset connections that didn't
if !conn.Completed && conn.State != connCompleted { // finish. On a checksum mismatch, reset EVERY connection —
if !f.hasSequentialPrefixLocked(conn) { // "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) f.resetConnectionForRestart(conn)
} }
conn.State = connNotStarted conn.State = connNotStarted
@@ -970,8 +1015,14 @@ func (f *Fetcher) expandConnections() {
} }
f.fileMu.Unlock() f.fileMu.Unlock()
f.setState(stateDone) var err error
f.doneCh <- nil if chkErr := f.verifyChecksum(); chkErr != nil {
err = chkErr
f.setState(stateError)
} else {
f.setState(stateDone)
}
f.doneCh <- err
return return
} }
@@ -2067,7 +2118,7 @@ func (f *Fetcher) resetConnectionForRestart(conn *connection) {
// so pause/retry must restart instead of continuing from the old offset. // so pause/retry must restart instead of continuing from the old offset.
if conn.Chunk == nil { if conn.Chunk == nil {
conn.Chunk = newChunk(0, 0) conn.Chunk = newChunk(0, 0)
} else { } else if !f.meta.Res.Range {
conn.Chunk.Begin = 0 conn.Chunk.Begin = 0
conn.Chunk.setEnd(0) conn.Chunk.setEnd(0)
conn.Chunk.Downloaded = 0 conn.Chunk.Downloaded = 0
@@ -2238,10 +2289,15 @@ func (f *Fetcher) onDownloadComplete() {
} }
f.fileMu.Unlock() f.fileMu.Unlock()
if finalErr != nil { if finalErr == nil {
f.setState(stateError) if chkErr := f.verifyChecksum(); chkErr != nil {
finalErr = chkErr
f.setState(stateError)
} else {
f.setState(stateDone)
}
} else { } else {
f.setState(stateDone) f.setState(stateError)
} }
select { select {
@@ -2250,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 { func (f *Fetcher) checkCompletion() bool {
// Check if all data has been downloaded // Check if all data has been downloaded
f.connMu.Lock() f.connMu.Lock()
@@ -0,0 +1,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" "net/url"
"os" "os"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@@ -853,22 +854,34 @@ func TestFetcher_InitialIgnoredRangeUsesResponseSize(t *testing.T) {
resolvePayload := bytes.Repeat([]byte("resolve"), 64*1024) resolvePayload := bytes.Repeat([]byte("resolve"), 64*1024)
replacement := bytes.Repeat([]byte("replacement"), 24*1024) replacement := bytes.Repeat([]byte("replacement"), 24*1024)
var rangeRequests atomic.Int32 var rangeRequests atomic.Int32
rangeStarted := make(chan struct{})
releaseResolve := sync.OnceFunc(func() { close(rangeStarted) })
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes) w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
if r.Header.Get(base.HttpHeaderRange) == "" { if r.Header.Get(base.HttpHeaderRange) == "" {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(resolvePayload))) w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(resolvePayload)))
w.WriteHeader(gohttp.StatusOK) w.WriteHeader(gohttp.StatusOK)
w.(gohttp.Flusher).Flush()
// Keep Resolve prefetch incomplete until the Range request starts.
// Otherwise Start may reuse the completed body and skip this scenario.
select {
case <-rangeStarted:
case <-r.Context().Done():
return
}
_, _ = w.Write(resolvePayload) _, _ = w.Write(resolvePayload)
return return
} }
rangeRequests.Add(1) rangeRequests.Add(1)
releaseResolve()
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(replacement))) w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(replacement)))
w.WriteHeader(gohttp.StatusOK) w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(replacement) _, _ = w.Write(replacement)
})) }))
defer server.Close() defer server.Close()
defer releaseResolve()
f := buildFetcher() f := buildFetcher()
if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}, &base.Options{ if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}, &base.Options{
@@ -0,0 +1,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)
}
}
+1
View File
@@ -1912,6 +1912,7 @@ func (d *Downloader) doPause(task *Task) (err error) {
} }
if handled { if handled {
d.emit(EventKeyPause, task) d.emit(EventKeyPause, task)
d.triggerWebhooks(WebhookEventDownloadPause, task, nil)
} }
return nil return nil
} }
@@ -0,0 +1,222 @@
package engine_test
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/GopeedLab/gopeed/pkg/download/engine"
)
// Exercise server-side header permissions, including formerly forbidden names,
// download authentication, metadata and non-safelisted no-cors values.
var extensionRequestHeaders = [][2]string{
{"Accept-Charset", "utf-8"}, {"Accept-Encoding", "identity"},
{"Access-Control-Request-Headers", "authorization, x-download"},
{"Access-Control-Request-Method", "POST"}, {"Connection", "close"},
{"Connection", "keep-alive"},
{"Content-Length", "4"}, {"Cookie", "sid=manual"}, {"Cookie2", "legacy=1"},
{"Date", "Fri, 09 Oct 2026 00:00:00 GMT"}, {"DNT", "1"},
{"Host", "download.example:1234"}, {"Origin", "https://download.example"},
{"Permissions-Policy", "camera=()"}, {"Referer", "https://download.example/file"},
{"TE", "trailers"}, {"Trailer", "X-Checksum"}, {"Set-Cookie", "sid=request"},
{"Set-Cookie2", "legacy=request"}, {"Via", "1.1 proxy"},
{"Proxy-Authorization", "Basic dGVzdA=="}, {"Proxy-", "custom"},
{"Sec-Fetch-Site", "same-origin"}, {"Sec-Fetch-Mode", "custom"},
{"Sec-CH-UA", `"Download client"`}, {"Sec-", "custom"},
{"X-HTTP-Method", "TRACE"}, {"X-HTTP-Method-Override", "CONNECT"},
{"X-Method-Override", "TRACK"}, {"Authorization", "Bearer token"},
{"User-Agent", "Gopeed extension"}, {"Content-Type", "application/json"},
{"Range", "bytes=0-1,3-4"}, {"X-Download-Token", "custom"},
{"Accept-Language", strings.Repeat("x", 129)},
}
func TestExtensionRequestsSendNodeHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
body, err := io.ReadAll(request.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := request.URL.Query().Get("header")
value := request.Header.Get(name)
if name == "Host" {
value = request.Host
}
_ = json.NewEncoder(w).Encode([]string{value, string(body)})
}))
t.Cleanup(server.Close)
clients := []struct{ name, script string }{
{"fetch record", `const response = await fetch(url, {method: "POST", body: "test", headers: {[name]: value}}); return (await response.text()).trim();`},
{"fetch no-cors Headers", `const response = await fetch(url, {method: "POST", body: "test", mode: "no-cors", headers: new Headers([[name.toLowerCase(), value]])}); return (await response.text()).trim();`},
{"fetch Request clone", `const request = new Request(url, {method: "POST", body: "test", mode: "same-origin", headers: [[name.toUpperCase(), value]]}); const response = await fetch(request.clone()); return (await response.text()).trim();`},
}
for _, credentials := range []bool{false, true} {
clients = append(clients, struct{ name, script string }{
fmt.Sprintf("XHR credentials=%t", credentials),
fmt.Sprintf(`return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.withCredentials = %t;
xhr.setRequestHeader(name.toLowerCase(), value);
xhr.onload = () => resolve(xhr.responseText.trim());
xhr.onerror = () => reject(new Error("XHR failed"));
xhr.send("test");
});`, credentials),
})
}
for _, header := range extensionRequestHeaders {
for _, client := range clients {
t.Run(header[0]+"/"+client.name, func(t *testing.T) {
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const name = %q, value = %q;
const url = %q + "?header=" + encodeURIComponent(name);
%s
})()`, header[0], header[1], server.URL, client.script))
if err != nil {
t.Fatal(err)
}
want, _ := json.Marshal([]string{header[1], "test"})
if value != string(want) {
t.Fatalf("server received %#v, want %s", value, want)
}
})
}
}
}
func TestExtensionHeaderMutationsMatchNode(t *testing.T) {
headers := append([][2]string(nil), extensionRequestHeaders...)
// Node stores these in Headers/Request, but rejects them during fetch.
headers = append(headers, [2]string{"Expect", "100-continue"}, [2]string{"Keep-Alive", "timeout=5"}, [2]string{"Transfer-Encoding", "chunked"}, [2]string{"Upgrade", "websocket"})
data, _ := json.Marshal(headers)
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
_, err := runtime.RunString(fmt.Sprintf(`(() => {
for (const mode of ["cors", "same-origin", "no-cors"]) {
for (const [name, value] of %s) {
const request = new Request("http://example.com", {mode, headers: {[name]: value}});
const headers = request.headers;
if (headers.get(name) !== value) throw new Error("Filtered init " + name + " in " + mode);
headers.delete(name.toUpperCase());
if (headers.has(name)) throw new Error("Cannot delete " + name);
headers.append(name, value);
if (headers.get(name) !== value) throw new Error("Cannot append " + name);
headers.set(name, value);
if (request.clone().headers.get(name) !== value) throw new Error("Cannot clone " + name);
}
}
return true;
})()`, data))
if err != nil {
t.Fatal(err)
}
}
func TestExtensionRequestsIgnoreUnsupportedTransportHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
body, err := io.ReadAll(request.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
value := request.Header.Get(request.URL.Query().Get("header"))
_ = json.NewEncoder(w).Encode([]string{value, string(body)})
}))
t.Cleanup(server.Close)
for _, header := range [][2]string{
{"Expect", "100-continue"}, {"Keep-Alive", "timeout=5"},
{"Transfer-Encoding", "chunked"}, {"Upgrade", "websocket"},
{"Connection", "upgrade"}, {"Content-Length", "3"},
{"Content-Length", "5"}, {"Content-Length", "invalid"},
{"Content-Length", "-1"}, {"Content-Length", ""},
} {
for _, xhr := range []bool{false, true} {
t.Run(fmt.Sprintf("%s=%s/XHR=%t", header[0], header[1], xhr), func(t *testing.T) {
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const name = %q, value = %q;
const url = %q + "?header=" + encodeURIComponent(name);
if (%t) return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.setRequestHeader(name, value);
xhr.onload = () => resolve(xhr.responseText.trim());
xhr.onerror = () => reject(new Error("XHR failed"));
xhr.send("test");
});
const response = await fetch(url, {method: "POST", body: "test", headers: {[name]: value}});
return (await response.text()).trim();
})()`, header[0], header[1], server.URL, xhr))
if err != nil {
t.Fatal(err)
}
wireValue := ""
if header[0] == "Content-Length" {
wireValue = "4"
}
want, _ := json.Marshal([]string{wireValue, "test"})
if value != string(want) {
t.Fatalf("server received %#v, want %s", value, want)
}
})
}
}
}
func TestExtensionResponseExposesSetCookie(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Add("Set-Cookie", "sid=one; HttpOnly")
w.Header().Add("Set-Cookie", "token=two")
w.Header().Set("Set-Cookie2", "legacy=three")
_, _ = w.Write([]byte("ok"))
}))
t.Cleanup(server.Close)
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const response = await fetch(%q);
const clone = response.clone();
await response.text(); await clone.text();
let immutable = false;
try { response.headers.set("Set-Cookie", "changed=1"); } catch (error) { immutable = error instanceof TypeError; }
const synthetic = new Response(null, {headers: {"Set-Cookie": "initial=1"}});
synthetic.headers.append("Set-Cookie", "appended=2");
return JSON.stringify({cookies: clone.headers.getSetCookie(), legacy: response.headers.get("set-cookie2"), immutable, synthetic: synthetic.headers.getSetCookie()});
})()`, server.URL))
if err != nil {
t.Fatal(err)
}
const want = `{"cookies":["sid=one; HttpOnly","token=two"],"legacy":"legacy=three","immutable":true,"synthetic":["initial=1","appended=2"]}`
if value != want {
t.Fatalf("unexpected response headers: got %#v want %s", value, want)
}
}
func TestExtensionHeadersRejectInvalidSyntax(t *testing.T) {
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
_, err := runtime.RunString(`(() => {
for (const [name, value] of [["Bad Header", "ok"], ["", "ok"], ["X:Header", "ok"],
["X-Test", "a\nb"], ["X-Test", "a\rb"], ["X-Test", "\0"], ["X-Test", "\u0100"]]) {
for (const create of [() => new Headers([[name, value]]),
() => new Request("http://example.com", {mode: "no-cors", headers: {[name]: value}}),
() => { const xhr = new XMLHttpRequest(); xhr.open("GET", "http://example.com"); xhr.setRequestHeader(name, value); }]) {
let rejected = false;
try { create(); } catch (error) { rejected = error instanceof TypeError; }
if (!rejected) throw new Error("Accepted invalid HTTP header syntax");
}
}
return true;
})()`)
if err != nil {
t.Fatal(err)
}
}
@@ -105,6 +105,105 @@ func TestXMLHttpRequestUsesFetch(t *testing.T) {
} }
} }
func TestExtensionRequestsSendExplicitCookie(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
_, _ = w.Write([]byte(request.Header.Get("Cookie")))
}))
t.Cleanup(server.Close)
type requestTest struct {
name string
script string
}
tests := []requestTest{
{"fetch record", `const response = await fetch(url, {headers: {Cookie: cookie}}); return response.text();`},
{"fetch Headers", `const response = await fetch(url, {headers: new Headers({cookie})}); return response.text();`},
{"fetch pairs", `const response = await fetch(url, {headers: [["cOoKiE", cookie]]}); return response.text();`},
{"fetch repeated Cookie", `const headers = new Headers([["Cookie", "sid=manual"], ["cookie", "token=provided"]]); if (headers.get("cookie") !== cookie) throw new Error("Incorrect Cookie separator"); const response = await fetch(url, {headers}); return response.text();`},
{"fetch Request clone", `const request = new Request(url, {headers: {Cookie: cookie}}); const response = await fetch(request.clone()); return response.text();`},
{"fetch Request set", `const request = new Request(url); request.headers.set("Cookie", cookie); const response = await fetch(request); return response.text();`},
{"fetch Request append", `const request = new Request(url); request.headers.append("Cookie", cookie); const response = await fetch(request); return response.text();`},
{"fetch no-cors mutations", `const request = new Request(url, {mode: "no-cors"}); request.headers.set("Cookie", "removed=1"); request.headers.delete("COOKIE"); if (request.headers.has("cookie")) throw new Error("Cookie was not deleted"); request.headers.append("Cookie", cookie); const response = await fetch(request.clone()); return response.text();`},
}
for _, mode := range []string{"cors", "same-origin", "no-cors"} {
for _, credentials := range []string{"omit", "same-origin", "include"} {
tests = append(tests, requestTest{
name: "fetch " + mode + " " + credentials,
script: fmt.Sprintf(`const response = await fetch(url, {headers: {Cookie: cookie}, mode: %q, credentials: %q}); return response.text();`, mode, credentials),
})
}
}
for _, withCredentials := range []bool{false, true} {
tests = append(tests, requestTest{
name: fmt.Sprintf("XHR withCredentials=%t", withCredentials),
script: fmt.Sprintf(`return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.withCredentials = %t;
xhr.setRequestHeader("cOoKiE", cookie);
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(new Error("XHR failed"));
xhr.send();
});`, withCredentials),
})
}
tests = append(tests, requestTest{"XHR repeated Cookie", `return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.setRequestHeader("Cookie", "sid=manual");
xhr.setRequestHeader("cookie", "token=provided");
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(new Error("XHR failed"));
xhr.send();
});`})
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
const cookie = "sid=manual; token=provided"
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const url = %q;
const cookie = %q;
%s
})()`, server.URL, cookie, test.script))
if err != nil {
t.Fatal(err)
}
if value != cookie {
t.Fatalf("server received Cookie %q, want %q", value, cookie)
}
})
}
}
func TestFetchExplicitCookieWithCookieJar(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/seed" {
http.SetCookie(w, &http.Cookie{Name: "stored", Value: "jar", Path: "/"})
}
_, _ = w.Write([]byte(request.Header.Get("Cookie")))
}))
t.Cleanup(server.Close)
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const url = %q;
await (await fetch(url + "/seed")).text();
const automatic = await (await fetch(url)).text();
const combined = await (await fetch(url, {headers: {Cookie: "manual=provided"}})).text();
const omitted = await (await fetch(url, {credentials: "omit", headers: {Cookie: "manual=provided"}})).text();
const empty = await (await fetch(url, {credentials: "omit"})).text();
return JSON.stringify({automatic, combined, omitted, empty});
})()`, server.URL))
if err != nil {
t.Fatal(err)
}
const want = `{"automatic":"stored=jar","combined":"manual=provided; stored=jar","omitted":"manual=provided","empty":""}`
if value != want {
t.Fatalf("unexpected CookieJar result: got %#v want %s", value, want)
}
}
func TestFetchResponseAndRequestCloneHaveIndependentBodies(t *testing.T) { func TestFetchResponseAndRequestCloneHaveIndependentBodies(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodPost { if request.Method == http.MethodPost {
+15 -60
View File
@@ -122,7 +122,7 @@
if (name === "set-cookie") { if (name === "set-cookie") {
for (const value of values) result.push([name, value]); for (const value of values) result.push([name, value]);
} else { } else {
result.push([name, values.join(", ")]); result.push([name, values.join(name === "cookie" ? "; " : ", ")]);
} }
} }
return result; return result;
@@ -158,49 +158,10 @@
return iterator; return iterator;
} }
function forbiddenRequestHeader(name, value) { function assertMutableHeaders(headers) {
if (name.startsWith("proxy-") || name.startsWith("sec-")) return true; // Match Node/Undici: HTTP syntax and immutability still apply, but browser
if ([ // forbidden-header and no-cors safelist rules do not apply to extensions.
"accept-charset", "accept-encoding", "access-control-request-headers",
"access-control-request-method", "connection", "content-length", "cookie",
"cookie2", "date", "dnt", "expect", "host", "keep-alive", "origin",
"permissions-policy", "referer", "te", "trailer", "transfer-encoding",
"set-cookie", "upgrade", "via",
].includes(name)) return true;
if (["x-http-method", "x-http-method-override", "x-method-override"].includes(name)) {
return String(value).split(",").some((method) => {
method = method.trim().toUpperCase();
return method === "CONNECT" || method === "TRACE" || method === "TRACK";
});
}
return false;
}
function noCORSSafelistedRequestHeader(name, value) {
if (value.length > 128) return false;
const hasUnsafeByte = /[\x00-\x08\x0A-\x1F\x7F"():<>?@\[\\\]{}]/.test(value);
if (name === "accept") return !hasUnsafeByte;
if (name === "accept-language" || name === "content-language") {
return /^[0-9A-Za-z *,\-.;=]*$/.test(value);
}
if (name === "content-type") {
if (hasUnsafeByte) return false;
const essence = value.split(";", 1)[0].trim().toLowerCase();
return essence === "application/x-www-form-urlencoded" ||
essence === "multipart/form-data" || essence === "text/plain";
}
if (name === "range") return /^bytes=[0-9]+-[0-9]*$/.test(value);
return false;
}
function blockedByGuard(headers, name, value) {
if (headers._guard === "immutable") throw new TypeError("Headers are immutable"); if (headers._guard === "immutable") throw new TypeError("Headers are immutable");
if (headers._guard === "request") return forbiddenRequestHeader(name, value);
if (headers._guard === "request-no-cors") {
return forbiddenRequestHeader(name, value) || !noCORSSafelistedRequestHeader(name, value);
}
if (headers._guard === "response") return name === "set-cookie" || name === "set-cookie2";
return false;
} }
class Headers { class Headers {
@@ -235,27 +196,21 @@
name = normalizeHeaderName(name); name = normalizeHeaderName(name);
value = normalizeHeaderValue(value); value = normalizeHeaderValue(value);
const values = this._values.get(name); const values = this._values.get(name);
const combinedValue = values && name !== "set-cookie" ? values.join(", ") + ", " + value : value; assertMutableHeaders(this);
if (blockedByGuard(this, name, combinedValue)) return;
if (values) values.push(value); if (values) values.push(value);
else this._values.set(name, [value]); else this._values.set(name, [value]);
} }
delete(name) { delete(name) {
name = normalizeHeaderName(name); name = normalizeHeaderName(name);
if (this._guard === "immutable") throw new TypeError("Headers are immutable"); assertMutableHeaders(this);
if (this._guard === "request-no-cors") {
if (forbiddenRequestHeader(name, "") || !["accept", "accept-language", "content-language", "content-type", "range"].includes(name)) return;
this._values.delete(name);
return;
}
if (blockedByGuard(this, name, "")) return;
this._values.delete(name); this._values.delete(name);
} }
get(name) { get(name) {
const values = this._values.get(normalizeHeaderName(name)); name = normalizeHeaderName(name);
return values ? values.join(", ") : null; const values = this._values.get(name);
return values ? values.join(name === "cookie" ? "; " : ", ") : null;
} }
getSetCookie() { getSetCookie() {
@@ -268,10 +223,10 @@
} }
set(name, value) { set(name, value) {
name = normalizeHeaderName(name); name = normalizeHeaderName(name);
value = normalizeHeaderValue(value); value = normalizeHeaderValue(value);
if (blockedByGuard(this, name, value)) return; assertMutableHeaders(this);
this._values.set(name, [value]); this._values.set(name, [value]);
} }
keys() { return createHeadersIterator(this, "key"); } keys() { return createHeadersIterator(this, "key"); }
@@ -484,7 +439,7 @@
} }
this._duplex = "half"; this._duplex = "half";
const headerInit = init.headers !== undefined ? init.headers : (source ? source.headers : undefined); const headerInit = init.headers !== undefined ? init.headers : (source ? source.headers : undefined);
this._headers = new Headers(headerInit, this._mode === "no-cors" ? "request-no-cors" : "request"); this._headers = new Headers(headerInit, "request");
let body = init.body !== undefined ? init.body : (source ? source._bodyInit : null); let body = init.body !== undefined ? init.body : (source ? source._bodyInit : null);
if (source && !bodyWasOverridden && source.body) body = transferReadableStream(source.body); if (source && !bodyWasOverridden && source.body) body = transferReadableStream(source.body);
const bodyIsStream = typeof ReadableStream !== "undefined" && body instanceof ReadableStream; const bodyIsStream = typeof ReadableStream !== "undefined" && body instanceof ReadableStream;
@@ -302,6 +302,23 @@ func (r *registry) Open(fingerprint string, proxyHandler func(*http.Request) (*u
return nil, err return nil, err
} }
for _, header := range metadata.Headers { for _, header := range metadata.Headers {
// Ignore transport controls unsupported by Node/Undici rather than
// applying a browser forbidden-header list or failing the whole request.
switch strings.ToLower(header[0]) {
case "expect", "keep-alive", "transfer-encoding", "upgrade", "content-length":
// Content-Length is generated from the buffered request body below.
continue
case "connection":
if value := strings.ToLower(header[1]); value != "close" && value != "keep-alive" {
continue
}
case "trailer":
// Fetch supplies a declaration, not a Go Request.Trailer payload.
// Keep its lower-case wire name so Go's writer does not omit it as
// the canonical Trailer field it normally generates itself.
httpRequest.Header["trailer"] = append(httpRequest.Header["trailer"], header[1])
continue
}
httpRequest.Header.Add(header[0], header[1]) httpRequest.Header.Add(header[0], header[1])
} }
if host := httpRequest.Header.Get("Host"); host != "" { if host := httpRequest.Header.Get("Host"); host != "" {
+26 -2
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 Gopeed executes applicable worker-style Fetch API tests at the extension
JavaScript boundary. The checked-in runner currently executes unchanged WPT JavaScript boundary. The checked-in runner currently executes unchanged WPT
tests for Headers, no-CORS header guards, Request construction and body tests for Headers syntax and iteration, Request construction and body
consumption, ReadableStream bodies, keepalive, Request errors, Response consumption, ReadableStream bodies, keepalive, Request errors, Response
construction and body consumption, immutable network response headers, and construction and body consumption, immutable network response headers, and
the Response static constructors. the Response static constructors.
@@ -19,10 +19,34 @@ a claim that Gopeed is a browser. Browser-only suites that require a document,
CORS origin enforcement or preflight, CSP, Mixed Content, Service Workers, CORS origin enforcement or preflight, CSP, Mixed Content, Service Workers,
browser HTTP cache, navigation, browser authentication UI, or WPT's browser HTTP cache, navigation, browser authentication UI, or WPT's
multi-origin server infrastructure are intentionally excluded. Gopeed also multi-origin server infrastructure are intentionally excluded. Gopeed also
keeps one extension-specific behavior: `redirect: "manual"` exposes the HTTP keeps extension-specific behavior: `redirect: "manual"` exposes the HTTP
redirect response so download extensions can inspect `Location`, instead of redirect response so download extensions can inspect `Location`, instead of
returning a browser `opaqueredirect` response. returning a browser `opaqueredirect` response.
Header permissions follow [Node/Undici's server-side Fetch API](https://github.com/nodejs/undici/blob/main/README.md#forbidden-and-safelisted-header-names). Gopeed does not
apply browser forbidden request/response header lists or no-CORS safelists.
Extensions may supply authentication, referrer, origin, host, proxy and fetch
metadata headers, and read `Set-Cookie` response headers. Network response
headers remain immutable; invalid header syntax still raises an error.
Repeated `Cookie` values are combined with `; `, as in Node/Undici.
The runner excludes the browser-only `headers-no-cors.any.js` and
`headers-forbidden-override.any.js` suites, the filtering assertions within
`request-headers.any.js`, and the forbidden-response-cookie assertion within
`header-setcookie.any.js`. Gopeed's `extension_headers_test.go` replaces those
restrictions with integration tests that check headers actually received by
an HTTP server through fetch and XMLHttpRequest, plus header mutations,
cloning and response cookies. The upstream test files remain unchanged.
Gopeed deliberately ignores transport controls that Node/Undici rejects:
`Expect`, `Keep-Alive`, `Transfer-Encoding`, `Upgrade`, and `Connection` values
other than `close` or `keep-alive`. `Content-Length` is generated from the
actual request body, ignoring a supplied length. Explicit `Host` and
`Sec-Fetch-Mode` values are preserved rather than replaced by defaults.
These transport behaviors are also covered by integration tests.
`credentials: "omit"` disables automatic CookieJar cookies but preserves
explicit headers; Gopeed retains its extension CookieJar support.
`wpt_harness.js` is a small Goja adapter maintained by Gopeed; it is not copied `wpt_harness.js` is a small Goja adapter maintained by Gopeed; it is not copied
from WPT. Test logic below `fetch/` is unchanged; the checked-in copy may only from WPT. Test logic below `fetch/` is unchanged; the checked-in copy may only
normalize a final newline. normalize a final newline.
+25 -17
View File
@@ -20,9 +20,6 @@ var wptFetchUtils string
//go:embed testdata/wpt/fetch/api/request/request-error.js //go:embed testdata/wpt/fetch/api/request/request-error.js
var wptRequestError string var wptRequestError string
//go:embed testdata/wpt/fetch/api/cors/resources/not-cors-safelisted.json
var wptNoCORSSafelistedHeaders []byte
//go:embed testdata/wpt/fetch/api/headers/*.any.js //go:embed testdata/wpt/fetch/api/headers/*.any.js
var wptHeaders embed.FS var wptHeaders embed.FS
@@ -30,31 +27,19 @@ var wptHeaders embed.FS
var wptRequestResponse embed.FS var wptRequestResponse embed.FS
func TestWPTHeaders(t *testing.T) { func TestWPTHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(wptNoCORSSafelistedHeaders)
}))
t.Cleanup(server.Close)
tests := []string{ tests := []string{
"header-setcookie.any.js", "header-setcookie.any.js",
"headers-basic.any.js", "headers-basic.any.js",
"headers-casing.any.js", "headers-casing.any.js",
"headers-combine.any.js", "headers-combine.any.js",
"headers-errors.any.js", "headers-errors.any.js",
"headers-forbidden-override.any.js",
"headers-normalize.any.js", "headers-normalize.any.js",
"headers-no-cors.any.js",
"headers-record.any.js", "headers-record.any.js",
"headers-structure.any.js", "headers-structure.any.js",
} }
for _, name := range tests { for _, name := range tests {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
setup := "" runWPTFile(t, wptHeaders, path.Join("testdata/wpt/fetch/api/headers", name), "")
if name == "headers-no-cors.any.js" {
setup = fmt.Sprintf("globalThis.location = new URL(%q);", server.URL+"/fetch/api/headers/test.any.js")
}
runWPTFile(t, wptHeaders, path.Join("testdata/wpt/fetch/api/headers", name), setup)
}) })
} }
} }
@@ -110,7 +95,30 @@ func runWPTFile(t *testing.T, files embed.FS, name, setup string) {
} }
runtime := engine.NewEngine(nil) runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close) t.Cleanup(runtime.Close)
value, err := runtime.RunString(setup + "\n" + wptHarness + "\n" + wptFetchUtils + "\n" + wptRequestError + "\n" + string(source) + "\n__wptFinish();") adaptation := ""
if name == "testdata/wpt/fetch/api/request/request-headers.any.js" {
// Node-style header permissions are covered by extension integration tests.
// Keep upstream tests unchanged and exclude browser-only restrictions.
adaptation = `
const upstreamTest = globalThis.test;
globalThis.test = function (callback, name) {
if (name.startsWith('Adding invalid request header ') ||
name.startsWith('Adding invalid no-cors request header ') ||
name.startsWith('Check that request constructor is filtering headers ') ||
name.startsWith('Check that no-cors request constructor is filtering headers ')) return;
upstreamTest(callback, name);
};
`
} else if name == "testdata/wpt/fetch/api/headers/header-setcookie.any.js" {
adaptation = `
const upstreamTest = globalThis.test;
globalThis.test = function (callback, name) {
if (name === 'Set-Cookie is a forbidden response header') return;
upstreamTest(callback, name);
};
`
}
value, err := runtime.RunString(setup + "\n" + wptHarness + "\n" + wptFetchUtils + "\n" + wptRequestError + "\n" + adaptation + "\n" + string(source) + "\n__wptFinish();")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+3
View File
@@ -23,6 +23,9 @@ type ResolveResult struct {
Res *base.Resource `json:"res"` Res *base.Resource `json:"res"`
} }
// Task represents a download unit.
// Options flow path:
// REST Request (model.CreateTask/ResolveTask) -> pkg/download Downloader -> Task.Meta.Opts (base.Options) -> Fetcher.Resolve/Start (e.g. internal/protocol/http/fetcher.go)
type Task struct { type Task struct {
ID string `json:"id"` ID string `json:"id"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
+1
View File
@@ -21,6 +21,7 @@ type WebhookEvent string
const ( const (
WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE" WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE"
WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR" WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
WebhookEventDownloadPause WebhookEvent = "DOWNLOAD_PAUSE"
) )
// WebhookData is the data sent to webhook URLs // WebhookData is the data sent to webhook URLs
+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)) { func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
defaultDownloader.Setup() defaultDownloader.Setup()
defaultDownloader.cfg.StorageDir = ".test_storage" defaultDownloader.cfg.StorageDir = ".test_storage"
+67 -7
View File
@@ -12,6 +12,7 @@ import (
"github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download" "github.com/GopeedLab/gopeed/pkg/download"
phttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/google/jsonschema-go/jsonschema" "github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@@ -65,12 +66,13 @@ type btRequestExtra struct {
} }
type httpTaskExtra struct { type httpTaskExtra struct {
Connections int `json:"connections,omitempty" jsonschema:"Number of HTTP download connections"` Connections int `json:"connections,omitempty" jsonschema:"Number of HTTP download connections"`
AutoTorrent *bool `json:"autoTorrent,omitempty" jsonschema:"Automatically create a BitTorrent task after downloading a torrent file"` AutoTorrent *bool `json:"autoTorrent,omitempty" jsonschema:"Automatically create a BitTorrent task after downloading a torrent file"`
DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload,omitempty" jsonschema:"Delete the torrent file after automatically creating its BitTorrent task"` DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload,omitempty" jsonschema:"Delete the torrent file after automatically creating its BitTorrent task"`
AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"` AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"`
ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"` ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"`
DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"` DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"`
Checksum *checksumOption `json:"checksum,omitempty" jsonschema:"Optional checksum verification settings"`
} }
// downloadRequest is the MCP-facing form of base.Request. The REST model uses // downloadRequest is the MCP-facing form of base.Request. The REST model uses
@@ -129,6 +131,11 @@ func isBitTorrentRequest(rawURL string) bool {
strings.HasPrefix(upperURL, "DATA:APPLICATION/X-BITTORRENT;BASE64,") strings.HasPrefix(upperURL, "DATA:APPLICATION/X-BITTORRENT;BASE64,")
} }
type checksumOption struct {
Algorithm string `json:"algorithm" jsonschema:"Checksum algorithm: md5, sha1, or sha256"`
Expected string `json:"expected" jsonschema:"Expected hex-encoded checksum hash"`
}
// downloadOptions is the MCP-facing form of base.Options. Only HTTP currently // downloadOptions is the MCP-facing form of base.Options. Only HTTP currently
// defines protocol-specific task options, so Extra has one concrete schema. // defines protocol-specific task options, so Extra has one concrete schema.
type downloadOptions struct { type downloadOptions struct {
@@ -186,12 +193,31 @@ func (o *downloadOptions) baseOptions() *base.Options {
if o == nil { if o == nil {
return nil return nil
} }
var extra any
if o.Extra != nil {
var checksum *phttp.ChecksumOption
if o.Extra.Checksum != nil {
checksum = &phttp.ChecksumOption{
Algorithm: o.Extra.Checksum.Algorithm,
Expected: o.Extra.Checksum.Expected,
}
}
extra = &phttp.OptsExtra{
Connections: o.Extra.Connections,
AutoTorrent: o.Extra.AutoTorrent,
DeleteTorrentAfterDownload: o.Extra.DeleteTorrentAfterDownload,
AutoExtract: o.Extra.AutoExtract,
ArchivePassword: o.Extra.ArchivePassword,
DeleteAfterExtract: o.Extra.DeleteAfterExtract,
Checksum: checksum,
}
}
return &base.Options{ return &base.Options{
Name: o.Name, Name: o.Name,
Path: o.Path, Path: o.Path,
AsDefaultPath: o.AsDefaultPath, AsDefaultPath: o.AsDefaultPath,
SelectFiles: o.SelectFiles, SelectFiles: o.SelectFiles,
Extra: o.Extra, Extra: extra,
} }
} }
@@ -270,6 +296,13 @@ type getTaskStatsOutput struct {
Stats any `json:"stats"` Stats any `json:"stats"`
} }
type getDownloadSpeedInput struct{}
type getDownloadSpeedOutput struct {
TotalSpeed int64 `json:"totalSpeed"`
ActiveTaskCount int `json:"activeTaskCount"`
}
type taskActionOutput struct { type taskActionOutput struct {
ID string `json:"id"` ID string `json:"id"`
Success bool `json:"success"` Success bool `json:"success"`
@@ -416,6 +449,33 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) {
return nil, &getTaskStatsOutput{Stats: stats}, nil return nil, &getTaskStatsOutput{Stats: stats}, nil
}) })
mcp.AddTool(server, &mcp.Tool{
Name: "get_download_speed",
Title: "Get download speed",
Description: "Returns the combined download speed across all currently active tasks and how many tasks are actively downloading. Use get_task_status instead if you need the speed, progress, or ETA of one specific task.",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closedWorld},
}, func(_ context.Context, _ *mcp.CallToolRequest, _ *getDownloadSpeedInput) (*mcp.CallToolResult, *getDownloadSpeedOutput, error) {
allTasks := downloader.GetTasks()
var totalSpeed int64
activeCount := 0
for _, task := range allTasks {
status, err := downloader.RuntimeStatus(task.ID)
if err != nil {
continue
}
if status.Status == base.DownloadStatusRunning {
activeCount++
totalSpeed += status.Speed
}
}
return nil, &getDownloadSpeedOutput{
TotalSpeed: totalSpeed,
ActiveTaskCount: activeCount,
}, nil
})
registerTaskAction(server, downloader, "pause_task", "Pause task", "Pause one Gopeed task.", &nondestructive, &closedWorld, downloader.Pause) registerTaskAction(server, downloader, "pause_task", "Pause task", "Pause one Gopeed task.", &nondestructive, &closedWorld, downloader.Pause)
registerTaskAction(server, downloader, "continue_task", "Continue task", "Continue a paused or failed Gopeed task.", &nondestructive, &openWorld, downloader.Continue) registerTaskAction(server, downloader, "continue_task", "Continue task", "Continue a paused or failed Gopeed task.", &nondestructive, &openWorld, downloader.Continue)
+1
View File
@@ -64,6 +64,7 @@ func TestStreamableHTTPTools(t *testing.T) {
"continue_task", "continue_task",
"create_task", "create_task",
"delete_task", "delete_task",
"get_download_speed",
"get_task", "get_task",
"get_task_stats", "get_task_stats",
"get_task_status", "get_task_status",
+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 package http
import (
"encoding/hex"
"fmt"
"strings"
)
type ReqExtra struct { type ReqExtra struct {
Method string `json:"method"` Method string `json:"method"`
Header map[string]string `json:"header"` Header map[string]string `json:"header"`
Body string `json:"body"` Body string `json:"body"`
} }
// ChecksumOption specifies an expected checksum to verify against
// the completed download. Only supported for HTTP downloads.
type ChecksumOption struct {
Algorithm string `json:"algorithm"` // "md5" | "sha1" | "sha256"
Expected string `json:"expected"` // hex-encoded expected hash
}
// 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 { type OptsExtra struct {
Connections int `json:"connections"` Connections int `json:"connections"`
// AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file // AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file
@@ -21,6 +93,8 @@ type OptsExtra struct {
ArchivePassword string `json:"archivePassword"` ArchivePassword string `json:"archivePassword"`
// DeleteAfterExtract when true, deletes the archive file after successful extraction // DeleteAfterExtract when true, deletes the archive file after successful extraction
DeleteAfterExtract bool `json:"deleteAfterExtract"` DeleteAfterExtract bool `json:"deleteAfterExtract"`
// Checksum verifies the completed file against an expected hash after download
Checksum *ChecksumOption `json:"checksum,omitempty"`
} }
// Stats for download // Stats for download
+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)
}
}
})
}
}
+17
View File
@@ -17,6 +17,21 @@ class Options {
Map<String, dynamic> toJson() => _$OptionsToJson(this); Map<String, dynamic> toJson() => _$OptionsToJson(this);
} }
@JsonSerializable()
class ChecksumOption {
String algorithm;
String expected;
ChecksumOption({
this.algorithm = '',
this.expected = '',
});
factory ChecksumOption.fromJson(Map<String, dynamic> json) => _$ChecksumOptionFromJson(json);
Map<String, dynamic> toJson() => _$ChecksumOptionToJson(this);
}
@JsonSerializable() @JsonSerializable()
class OptsExtraHttp { class OptsExtraHttp {
int connections; int connections;
@@ -25,6 +40,7 @@ class OptsExtraHttp {
bool? autoExtract; bool? autoExtract;
String archivePassword; String archivePassword;
bool deleteAfterExtract; bool deleteAfterExtract;
ChecksumOption? checksum;
OptsExtraHttp({ OptsExtraHttp({
this.connections = 0, this.connections = 0,
@@ -33,6 +49,7 @@ class OptsExtraHttp {
this.autoExtract, this.autoExtract,
this.archivePassword = '', this.archivePassword = '',
this.deleteAfterExtract = false, this.deleteAfterExtract = false,
this.checksum,
}); });
factory OptsExtraHttp.fromJson(Map<String, dynamic> json) => _$OptsExtraHttpFromJson(json); factory OptsExtraHttp.fromJson(Map<String, dynamic> json) => _$OptsExtraHttpFromJson(json);
+14
View File
@@ -22,6 +22,16 @@ Map<String, dynamic> _$OptionsToJson(Options instance) => <String, dynamic>{
'extra': ?instance.extra, 'extra': ?instance.extra,
}; };
ChecksumOption _$ChecksumOptionFromJson(Map<String, dynamic> json) => ChecksumOption(
algorithm: json['algorithm'] as String? ?? '',
expected: json['expected'] as String? ?? '',
);
Map<String, dynamic> _$ChecksumOptionToJson(ChecksumOption instance) => <String, dynamic>{
'algorithm': instance.algorithm,
'expected': instance.expected,
};
OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHttp( OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHttp(
connections: (json['connections'] as num?)?.toInt() ?? 0, connections: (json['connections'] as num?)?.toInt() ?? 0,
autoTorrent: json['autoTorrent'] as bool?, autoTorrent: json['autoTorrent'] as bool?,
@@ -29,6 +39,9 @@ OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHtt
autoExtract: json['autoExtract'] as bool?, autoExtract: json['autoExtract'] as bool?,
archivePassword: json['archivePassword'] as String? ?? '', archivePassword: json['archivePassword'] as String? ?? '',
deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false, deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false,
checksum: json['checksum'] == null
? null
: ChecksumOption.fromJson(json['checksum'] as Map<String, dynamic>),
); );
Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, dynamic>{ Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, dynamic>{
@@ -38,4 +51,5 @@ Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, d
'autoExtract': ?instance.autoExtract, 'autoExtract': ?instance.autoExtract,
'archivePassword': instance.archivePassword, 'archivePassword': instance.archivePassword,
'deleteAfterExtract': instance.deleteAfterExtract, 'deleteAfterExtract': instance.deleteAfterExtract,
'checksum': ?instance.checksum?.toJson(),
}; };
@@ -185,12 +185,11 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
} }
bool canUpdateItem(ExtensionListItem item) { bool canUpdateItem(ExtensionListItem item) {
final current = _current; final installed = item.installed;
if (item.installed == null) return false; if (installed == null) return false;
if (current.listFilter == ExtensionListFilter.market && item.store != null) { // The backend checks GitHub live for every installed extension; the store
return _compareVersion(item.store!.version, item.installed!.version) > 0; // index may lag behind and must not decide update visibility.
} return _current.updateFlags.containsKey(installed.identity);
return current.updateFlags.containsKey(item.installed!.identity);
} }
Future<void> installFromStore(StoreExtension extension) async { Future<void> installFromStore(StoreExtension extension) async {
@@ -240,6 +239,9 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
await _runBusy(extension.identity, () async { await _runBusy(extension.identity, () async {
await ref.read(gopeedServiceProvider).updateExtension(extension.identity); await ref.read(gopeedServiceProvider).updateExtension(extension.identity);
await loadInstalled(refreshUpdates: false); await loadInstalled(refreshUpdates: false);
// Drop the stale flag immediately; the background re-check confirms it.
final flags = Map<String, String>.of(_current.updateFlags)..remove(extension.identity);
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
unawaited(checkUpdate()); unawaited(checkUpdate());
_bumpStoreInstallCount(extension.identity); _bumpStoreInstallCount(extension.identity);
_reportInstallSafe(extension.identity); _reportInstallSafe(extension.identity);
@@ -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 '../../../../shared/widgets/detail/app_detail_surface.dart';
import '../../application/extensions_controller.dart'; import '../../application/extensions_controller.dart';
import '../widgets/extension_detail_view.dart'; import '../widgets/extension_detail_view.dart';
import '../widgets/extension_update_dialog.dart';
import '../widgets/extension_update_status.dart';
class ExtensionDetailsPage extends ConsumerWidget { class ExtensionDetailsPage extends ConsumerWidget {
const ExtensionDetailsPage({super.key, required this.extensionId, this.initialItem}); const ExtensionDetailsPage({super.key, required this.extensionId, this.initialItem});
@@ -24,6 +26,15 @@ class ExtensionDetailsPage extends ConsumerWidget {
return AppDetailPage( return AppDetailPage(
title: item?.title ?? context.l10n.extensions, title: item?.title ?? context.l10n.extensions,
onBack: () => context.canPop() ? context.pop() : context.go('/extensions'), onBack: () => context.canPop() ? context.pop() : context.go('/extensions'),
titleTrailing: _canUpdate(stateAsync.value, item)
? ExtensionUpdateStatus(
key: const ValueKey('extension-details-update-mobile'),
label: context.l10n.extensionCanUpdate,
onTap: item?.installed == null
? null
: () => showExtensionUpdateDialog(context, item!.installed!),
)
: null,
child: item == null child: item == null
? Center( ? Center(
child: stateAsync.isLoading child: stateAsync.isLoading
@@ -33,4 +44,9 @@ class ExtensionDetailsPage extends ConsumerWidget {
: ExtensionDetailView(key: ValueKey(item.id), item: item, mobile: true), : ExtensionDetailView(key: ValueKey(item.id), item: item, mobile: true),
); );
} }
bool _canUpdate(ExtensionsState? state, ExtensionListItem? item) {
final installed = item?.installed;
return item != null && installed != null && (state?.updateFlags.containsKey(installed.identity) ?? false);
}
} }
@@ -28,6 +28,7 @@ import '../widgets/extension_detail_view.dart';
import '../widgets/extension_icon.dart'; import '../widgets/extension_icon.dart';
import '../widgets/extension_setting_field.dart'; import '../widgets/extension_setting_field.dart';
import '../widgets/extension_update_dialog.dart'; import '../widgets/extension_update_dialog.dart';
import '../widgets/extension_update_status.dart';
const _extensionCardMinWidth = 290.0; const _extensionCardMinWidth = 290.0;
const _extensionGridSpacing = 10.0; const _extensionGridSpacing = 10.0;
@@ -485,25 +486,7 @@ class _Content extends StatelessWidget {
} }
bool _canUpdate(ExtensionsState state, ExtensionListItem item) { bool _canUpdate(ExtensionsState state, ExtensionListItem item) {
if (item.installed == null) return false; return item.installed != null && state.updateFlags.containsKey(item.installed!.identity);
if (state.listFilter == ExtensionListFilter.market && item.store != null) {
return _compareVersion(item.store!.version, item.installed!.version) > 0;
}
return state.updateFlags.containsKey(item.installed!.identity);
}
int _compareVersion(String a, String b) {
final aNums = a.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0);
final bNums = b.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0);
final left = aNums.toList();
final right = bNums.toList();
final maxLen = left.length > right.length ? left.length : right.length;
for (var i = 0; i < maxLen; i++) {
final l = i < left.length ? left[i] : 0;
final r = i < right.length ? right[i] : 0;
if (l != r) return l.compareTo(r);
}
return 0;
} }
} }
@@ -994,11 +977,28 @@ class _ExtensionCard extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
item.title, children: [
maxLines: 1, Flexible(
overflow: TextOverflow.ellipsis, child: Text(
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700), item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700),
),
),
if (canUpdate && installed != null) ...[
const SizedBox(width: 4),
Flexible(
child: ExtensionUpdateStatus(
key: ValueKey('extension-card-update-status-${item.id}'),
label: context.l10n.extensionCanUpdate,
onTap: busy ? null : () => showExtensionUpdateDialog(context, installed),
compact: true,
),
),
],
],
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
@@ -1067,13 +1067,6 @@ class _ExtensionCard extends ConsumerWidget {
key: ValueKey('extension-card-actions-${item.id}'), key: ValueKey('extension-card-actions-${item.id}'),
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (canUpdate && installed != null)
shad.GhostButton(
density: shad.ButtonDensity.icon,
key: ValueKey('update-extension-${installed.identity}'),
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
child: const Icon(Icons.refresh),
),
if (installed == null && item.store != null) if (installed == null && item.store != null)
shad.GhostButton( shad.GhostButton(
key: ValueKey('install-store-extension-${item.store!.id}'), key: ValueKey('install-store-extension-${item.store!.id}'),
@@ -1127,21 +1120,17 @@ class _ExtensionCard extends ConsumerWidget {
], ],
), ),
); );
if (item.store == null) return card; return GestureDetector(
return MouseRegion( key: ValueKey('open-extension-details-${item.id}'),
cursor: SystemMouseCursors.click, behavior: HitTestBehavior.opaque,
child: GestureDetector( onTap: () {
key: ValueKey('open-extension-details-${item.id}'), if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) {
behavior: HitTestBehavior.opaque, context.push('/extensions/${Uri.encodeComponent(item.id)}', extra: item);
onTap: () { return;
if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) { }
context.push('/extensions/${Uri.encodeComponent(item.id)}', extra: item); onOpenDetails(item);
return; },
} child: card,
onOpenDetails(item);
},
child: card,
),
); );
} }
} }
@@ -21,20 +21,33 @@ import '../../../../util/util.dart';
import '../../application/extensions_controller.dart'; import '../../application/extensions_controller.dart';
import 'extension_icon.dart'; import 'extension_icon.dart';
import 'extension_update_dialog.dart'; import 'extension_update_dialog.dart';
import 'extension_update_status.dart';
class ExtensionDetailDrawer extends StatelessWidget { class ExtensionDetailDrawer extends ConsumerWidget {
const ExtensionDetailDrawer({super.key, required this.item, required this.onClose}); const ExtensionDetailDrawer({super.key, required this.item, required this.onClose});
final ExtensionListItem? item; final ExtensionListItem? item;
final VoidCallback onClose; final VoidCallback onClose;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(extensionsControllerProvider).value;
final installed = item?.installed;
final canUpdate =
item != null && installed != null && (state?.updateFlags.containsKey(installed.identity) ?? false);
final updateChip = canUpdate
? ExtensionUpdateStatus(
key: const ValueKey('extension-details-update'),
label: context.l10n.extensionCanUpdate,
onTap: () => showExtensionUpdateDialog(context, installed),
)
: null;
return AppDetailDrawer( return AppDetailDrawer(
open: item != null, open: item != null,
title: item?.title ?? '', title: item?.title ?? '',
onClose: onClose, onClose: onClose,
drawerKey: const ValueKey('extension-details-drawer'), drawerKey: const ValueKey('extension-details-drawer'),
titleTrailing: updateChip,
child: item == null ? const SizedBox.shrink() : ExtensionDetailView(item: item!), child: item == null ? const SizedBox.shrink() : ExtensionDetailView(item: item!),
); );
} }
@@ -104,8 +117,6 @@ class ExtensionDetailView extends ConsumerWidget {
style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600), style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600),
), ),
if (store != null) _ExtensionStats(store: store), if (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)), _runAction(context, () => ref.read(extensionsControllerProvider.notifier).installFromStore(store)),
child: Text(context.l10n.extensionInstall), child: Text(context.l10n.extensionInstall),
), ),
if (installed != null && canUpdate)
AppLoadingButton(
key: const ValueKey('extension-details-update'),
loading: false,
variant: AppLoadingButtonVariant.primary,
icon: const Icon(Icons.refresh, size: 16),
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
child: Text(context.l10n.newVersionUpdate),
),
if (installed != null && !canUpdate) if (installed != null && !canUpdate)
AppLoadingButton( AppLoadingButton(
key: const ValueKey('extension-details-installed'), key: const ValueKey('extension-details-installed'),
@@ -162,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 { 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 ExtensionListItem item;
final bool mobile; final bool mobile;
@@ -190,7 +198,7 @@ class _ExtensionReadme extends StatefulWidget {
} }
class _ExtensionReadmeState extends State<_ExtensionReadme> { class _ExtensionReadmeState extends State<_ExtensionReadme> {
late Future<_ReadmeInfo> _readme; late final _ReadmeInfo _readme;
@override @override
void initState() { void initState() {
@@ -198,109 +206,48 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> {
_readme = _loadReadme(widget.item); _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final palette = AppPalette.of(context); final palette = AppPalette.of(context);
return FutureBuilder<_ReadmeInfo>( final info = _readme;
future: _readme, if (info.content.trim().isEmpty) return const SizedBox.shrink();
builder: (context, snapshot) { return Padding(
final info = snapshot.data; key: const ValueKey('extension-details-readme'),
if (info == null) { padding: EdgeInsets.only(top: widget.mobile ? 32 : 36),
return const SizedBox.shrink(); child: Column(
} crossAxisAlignment: CrossAxisAlignment.start,
if (info.content.trim().isEmpty) { children: [
return const SizedBox.shrink(); Text(
} 'README',
return Padding( style: TextStyle(color: palette.textMuted, fontSize: 10.5, fontWeight: FontWeight.w800, letterSpacing: 1.2),
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),
);
},
),
],
), ),
); const SizedBox(height: 16),
}, MarkdownBody(
); data: info.content,
} selectable: true,
} styleSheet: _markdownStyle(palette, mobile: widget.mobile),
onTapLink: (_, href, _) {
class _ExtensionStatus extends StatelessWidget { final resolved = _resolveReadmeUrl(widget.item, href, info: info, forImage: false);
const _ExtensionStatus({required this.label, required this.emphasized}); if (resolved != null) unawaited(_openUrl(resolved));
},
final String label; imageBuilder: (uri, title, alt) {
final bool emphasized; final resolved = _resolveReadmeUrl(widget.item, uri.toString(), info: info, forImage: true);
if (resolved == null) return const SizedBox.shrink();
@override final image = resolved.startsWith('file:')
Widget build(BuildContext context) { ? Image.file(
final palette = AppPalette.of(context); File(Uri.parse(resolved).toFilePath()),
final dotColor = emphasized ? palette.brand : palette.success; fit: BoxFit.contain,
final textColor = emphasized ? palette.textPrimary : palette.success; errorBuilder: (_, _, _) => const SizedBox.shrink(),
return Row( )
mainAxisSize: MainAxisSize.min, : Image.network(resolved, fit: BoxFit.contain, errorBuilder: (_, _, _) => const SizedBox.shrink());
children: [ return Padding(
Container( padding: const EdgeInsets.symmetric(vertical: 10),
width: 5, child: ClipRRect(borderRadius: BorderRadius.circular(6), child: image),
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),
),
],
); );
} }
} }
@@ -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 installed = item.installed;
final remote = item.store?.readme ?? ''; final remote = item.store?.readme ?? '';
if (installed == null || Util.isWeb()) return _ReadmeInfo(content: remote); if (installed == null || Util.isWeb()) return _ReadmeInfo(content: remote);
@@ -58,7 +58,7 @@ class _ExtensionUpdateDialogState extends ConsumerState<_ExtensionUpdateDialog>
canPop: !_updating, canPop: !_updating,
child: AlertDialog( child: AlertDialog(
key: const ValueKey('extension-update-dialog'), key: const ValueKey('extension-update-dialog'),
title: Text(context.l10n.extensionCanUpdate), title: Text(context.l10n.newVersionAvailable),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -0,0 +1,59 @@
import 'package:flutter/widgets.dart';
import '../../../../shared/theme/app_palette.dart';
/// Green status dot with an optional label, marking an available update.
/// Pass [onTap] to make it open the update prompt.
class ExtensionUpdateStatus extends StatelessWidget {
const ExtensionUpdateStatus({super.key, this.label, this.onTap, this.compact = false});
final String? label;
final VoidCallback? onTap;
final bool compact;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final content = Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: compact ? 2 : 3),
decoration: BoxDecoration(
color: palette.brandSoft,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: palette.brand.withValues(alpha: 0.35)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: palette.brand, shape: BoxShape.circle),
),
if (label != null) ...[
const SizedBox(width: 5),
Flexible(
child: Text(
label!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 11.5, fontWeight: FontWeight.w600),
),
),
],
],
),
);
if (onTap == null) return content;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 2, vertical: compact ? 2 : 4),
child: content,
),
),
);
}
}
@@ -66,6 +66,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
final _httpHeadersController = AppHttpHeadersController(defaultNames: const ['User-Agent', 'Cookie', 'Referer']); final _httpHeadersController = AppHttpHeadersController(defaultNames: const ['User-Agent', 'Cookie', 'Referer']);
final _trackersController = TextEditingController(); final _trackersController = TextEditingController();
final _archivePasswordController = TextEditingController(); final _archivePasswordController = TextEditingController();
final _checksumHashController = TextEditingController();
final _formScrollController = ScrollController(); final _formScrollController = ScrollController();
String _httpMethod = 'GET'; String _httpMethod = 'GET';
@@ -74,6 +75,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
Map<String, String>? _initialLabels; Map<String, String>? _initialLabels;
RequestProxyMode _proxyMode = RequestProxyMode.follow; RequestProxyMode _proxyMode = RequestProxyMode.follow;
String _proxyScheme = 'http'; String _proxyScheme = 'http';
String _checksumAlgorithm = 'md5';
bool _skipVerifyCert = false; bool _skipVerifyCert = false;
bool? _autoTorrent; bool? _autoTorrent;
bool? _deleteTorrentAfterDownload; bool? _deleteTorrentAfterDownload;
@@ -134,6 +136,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_httpHeadersController.dispose(); _httpHeadersController.dispose();
_trackersController.dispose(); _trackersController.dispose();
_archivePasswordController.dispose(); _archivePasswordController.dispose();
_checksumHashController.dispose();
_formScrollController.dispose(); _formScrollController.dispose();
super.dispose(); super.dispose();
} }
@@ -184,6 +187,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) { if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) {
_fileDataUri = ''; _fileDataUri = '';
} }
if (_inputUrls().length > 1) {
_checksumHashController.clear();
_checksumAlgorithm = 'md5';
}
} }
_recognizeMagnetUri(urlText.trim()); _recognizeMagnetUri(urlText.trim());
} }
@@ -193,6 +200,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
final req = task.req; final req = task.req;
final opts = task.opts; final opts = task.opts;
setState(() { setState(() {
_checksumAlgorithm = 'md5';
_checksumHashController.clear();
if (req?.url.isNotEmpty == true) { if (req?.url.isNotEmpty == true) {
_fileDataUri = ''; _fileDataUri = '';
_renameController.clear(); _renameController.clear();
@@ -243,6 +252,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_autoExtract = extra.autoExtract; _autoExtract = extra.autoExtract;
_archivePasswordController.text = extra.archivePassword; _archivePasswordController.text = extra.archivePassword;
_deleteAfterExtract = extra.deleteAfterExtract; _deleteAfterExtract = extra.deleteAfterExtract;
if (extra.checksum != null) {
if (extra.checksum!.algorithm.isNotEmpty) {
_checksumAlgorithm = extra.checksum!.algorithm;
}
_checksumHashController.text = extra.checksum!.expected;
}
} }
} }
}); });
@@ -668,6 +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 ...[ ] else ...[
AppFormRow( AppFormRow(
direction: stacked ? Axis.vertical : Axis.horizontal, direction: stacked ? Axis.vertical : Axis.horizontal,
@@ -950,6 +993,25 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
} }
bool _validateAdvancedOptions() { 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) { if (_proxyMode != RequestProxyMode.custom) {
return true; return true;
} }
@@ -1022,8 +1084,15 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
return text.length == 40 && RegExp(r'^[0-9a-fA-F]+$').hasMatch(text); 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 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( return api_options.Options(
name: name ?? _renameController.text.trim(), name: name ?? _renameController.text.trim(),
path: _directoryController.text.trim(), path: _directoryController.text.trim(),
@@ -1036,6 +1105,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
autoExtract: _autoExtract, autoExtract: _autoExtract,
archivePassword: _archivePasswordController.text, archivePassword: _archivePasswordController.text,
deleteAfterExtract: _deleteAfterExtract, deleteAfterExtract: _deleteAfterExtract,
checksum: checksum,
).toJson(), ).toJson(),
); );
} }
@@ -1050,13 +1120,14 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
Future<void> _submitResolved(Request request, ResolveResult result, List<int> selectedIndexes) async { Future<void> _submitResolved(Request request, ResolveResult result, List<int> selectedIndexes) async {
final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList()) final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList())
..sort(); ..sort();
final isMultiFile = selected.length > 1;
if (result.id.isNotEmpty) { if (result.id.isNotEmpty) {
await ref await ref
.read(gopeedServiceProvider) .read(gopeedServiceProvider)
.createTask( .createTask(
CreateTask( CreateTask(
rid: result.id, rid: result.id,
opts: _buildOptions(selectFiles: selected), opts: _buildOptions(selectFiles: selected, omitChecksum: isMultiFile),
), ),
); );
return; return;
@@ -1070,11 +1141,11 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
opts: api_options.Options( opts: api_options.Options(
name: file.name, name: file.name,
path: path.join(_directoryController.text.trim(), result.res.name, file.path), path: path.join(_directoryController.text.trim(), result.res.name, file.path),
extra: _buildOptions().extra, extra: _buildOptions(omitChecksum: isMultiFile).extra,
), ),
); );
}).toList(); }).toList();
await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions())); await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions(omitChecksum: isMultiFile)));
return; return;
} }
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "إضافة", "add": "إضافة",
"edit": "تعديل", "edit": "تعديل",
"newVersionTitle": "يتوفر إصدار جديد @version", "newVersionTitle": "يتوفر إصدار جديد @version",
"newVersionUpdate": "تحديث الآن", "newVersionUpdate": "تحديث",
"newVersionLater": "لاحقاً", "newVersionLater": "لاحقاً",
"extensions": "الإضافات", "extensions": "الإضافات",
"extensionInstallUrl": "عنوان التثبيت", "extensionInstallUrl": "عنوان التثبيت",
@@ -164,6 +164,11 @@
"autoExtract": "فك الضغط تلقائياً", "autoExtract": "فك الضغط تلقائياً",
"archivePassword": "كلمة مرور الملف المضغوط", "archivePassword": "كلمة مرور الملف المضغوط",
"archivePasswordHint": "اتركه فارغاً إذا لم يكن محمياً بكلمة مرور", "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": "حذف الملف المضغوط بعد فك الضغط", "deleteAfterExtract": "حذف الملف المضغوط بعد فك الضغط",
"extracting": "جاري فك الضغط", "extracting": "جاري فك الضغط",
"extractDone": "تم فك الضغط بنجاح", "extractDone": "تم فك الضغط بنجاح",
+6 -1
View File
@@ -106,7 +106,7 @@
"add": "Əlavə et", "add": "Əlavə et",
"edit": "Redaktə et", "edit": "Redaktə et",
"newVersionTitle": "Yeni versiya @version tapıldı", "newVersionTitle": "Yeni versiya @version tapıldı",
"newVersionUpdate": "İndi Yenilə", "newVersionUpdate": "Yenilə",
"newVersionLater": "Sonra", "newVersionLater": "Sonra",
"extensions": "Uzantılar", "extensions": "Uzantılar",
"extensionInstallUrl": "Quraşdırma URL-i", "extensionInstallUrl": "Quraşdırma URL-i",
@@ -171,6 +171,11 @@
"autoExtract": "Arxivləri Avtomatik Çıxar", "autoExtract": "Arxivləri Avtomatik Çıxar",
"archivePassword": "Arxiv Parolu", "archivePassword": "Arxiv Parolu",
"archivePasswordHint": "Parolla qorunmayıbsa boş buraxın", "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", "deleteAfterExtract": "Çıxarıldıqdan sonra arxivi sil",
"extracting": "Çıxarılır", "extracting": "Çıxarılır",
"extractDone": "Çıxarma tamamlandı", "extractDone": "Çıxarma tamamlandı",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Afegeix", "add": "Afegeix",
"edit": "Edita", "edit": "Edita",
"newVersionTitle": "Descobriu la nova versió @version", "newVersionTitle": "Descobriu la nova versió @version",
"newVersionUpdate": "Actualitza ara", "newVersionUpdate": "Actualitza",
"newVersionLater": "Més tard", "newVersionLater": "Més tard",
"extensions": "Extensions", "extensions": "Extensions",
"extensionInstallUrl": "URL d'instal·lació", "extensionInstallUrl": "URL d'instal·lació",
@@ -164,6 +164,11 @@
"autoExtract": "Extreu els comprimits automàticament", "autoExtract": "Extreu els comprimits automàticament",
"archivePassword": "Contrasenya del comprimit", "archivePassword": "Contrasenya del comprimit",
"archivePasswordHint": "Deixeu-lo en blanc si no està protegit amb contrasenya", "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ó", "deleteAfterExtract": "Suprimeix el comprimit després de l'extracció",
"extracting": "S'està extraient", "extracting": "S'està extraient",
"extractDone": "Extracció completada", "extractDone": "Extracció completada",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Hinzugefügt", "add": "Hinzugefügt",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"newVersionTitle": "Entdecken Sie die neue Version @version", "newVersionTitle": "Entdecken Sie die neue Version @version",
"newVersionUpdate": "Jetzt aktualisieren", "newVersionUpdate": "Aktualisieren",
"newVersionLater": "Später", "newVersionLater": "Später",
"extensions": "Erweiterungen", "extensions": "Erweiterungen",
"extensionInstallUrl": "Installations-URL", "extensionInstallUrl": "Installations-URL",
@@ -164,6 +164,11 @@
"autoExtract": "Archive automatisch entpacken", "autoExtract": "Archive automatisch entpacken",
"archivePassword": "Archiv-Passwort", "archivePassword": "Archiv-Passwort",
"archivePasswordHint": "Leer lassen, wenn nicht passwortgeschützt", "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", "deleteAfterExtract": "Archiv nach Entpacken löschen",
"extracting": "Wird entpackt", "extracting": "Wird entpackt",
"extractDone": "Entpacken abgeschlossen", "extractDone": "Entpacken abgeschlossen",
+7 -2
View File
@@ -106,7 +106,7 @@
"add": "Add", "add": "Add",
"edit": "Edit", "edit": "Edit",
"newVersionTitle": "Discover new version @version", "newVersionTitle": "Discover new version @version",
"newVersionUpdate": "Update Now", "newVersionUpdate": "Update",
"newVersionLater": "Later", "newVersionLater": "Later",
"extensions": "Extensions", "extensions": "Extensions",
"extensionInstallUrl": "Install URL", "extensionInstallUrl": "Install URL",
@@ -137,7 +137,7 @@
"extensionLoadMore": "Load More", "extensionLoadMore": "Load More",
"extensionNoMore": "No more extensions", "extensionNoMore": "No more extensions",
"extensionInstalled": "Installed", "extensionInstalled": "Installed",
"extensionCanUpdate": "Update Available", "extensionCanUpdate": "Update",
"extensionInstall": "Install", "extensionInstall": "Install",
"extensionLoadLocal": "Install Local Folder", "extensionLoadLocal": "Install Local Folder",
"extensionFilterAll": "All", "extensionFilterAll": "All",
@@ -178,6 +178,11 @@
"autoExtract": "Extract Archives Automatically", "autoExtract": "Extract Archives Automatically",
"archivePassword": "Archive Password", "archivePassword": "Archive Password",
"archivePasswordHint": "Leave empty if not password protected", "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", "deleteAfterExtract": "Delete Archives After Extraction",
"extracting": "Extracting", "extracting": "Extracting",
"extractDone": "Extraction completed", "extractDone": "Extraction completed",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Añadir", "add": "Añadir",
"edit": "Editar", "edit": "Editar",
"newVersionTitle": "Nueva versión @version disponible", "newVersionTitle": "Nueva versión @version disponible",
"newVersionUpdate": "Actualizar Ahora", "newVersionUpdate": "Actualizar",
"newVersionLater": "Más tarde", "newVersionLater": "Más tarde",
"extensions": "Extensiones", "extensions": "Extensiones",
"extensionInstallUrl": "URL de instalación", "extensionInstallUrl": "URL de instalación",
@@ -164,6 +164,11 @@
"autoExtract": "Extraer archivos automáticamente", "autoExtract": "Extraer archivos automáticamente",
"archivePassword": "Contraseña del archivo", "archivePassword": "Contraseña del archivo",
"archivePasswordHint": "Dejar vacío si no tiene contraseña", "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", "deleteAfterExtract": "Eliminar archivo después de extraer",
"extracting": "Extrayendo", "extracting": "Extrayendo",
"extractDone": "Extracción completada", "extractDone": "Extracción completada",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "افزودن", "add": "افزودن",
"edit": "ویرایش", "edit": "ویرایش",
"newVersionTitle": "@version عنوان: کشف نسخه جدید", "newVersionTitle": "@version عنوان: کشف نسخه جدید",
"newVersionUpdate": "بروزرسانی", "newVersionUpdate": "به‌روزرسانی",
"newVersionLater": "بعداً", "newVersionLater": "بعداً",
"extensions": "افزونه‌ها", "extensions": "افزونه‌ها",
"extensionInstallUrl": "نشانی نصب", "extensionInstallUrl": "نشانی نصب",
@@ -164,6 +164,11 @@
"autoExtract": "استخراج خودکار بایگانی‌ها", "autoExtract": "استخراج خودکار بایگانی‌ها",
"archivePassword": "رمز عبور بایگانی", "archivePassword": "رمز عبور بایگانی",
"archivePasswordHint": "اگر رمز ندارد خالی بگذارید", "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": "حذف آرشیو پس از استخراج", "deleteAfterExtract": "حذف آرشیو پس از استخراج",
"extracting": "در حال استخراج", "extracting": "در حال استخراج",
"extractDone": "استخراج کامل شد", "extractDone": "استخراج کامل شد",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Ajouter", "add": "Ajouter",
"edit": "Modifier", "edit": "Modifier",
"newVersionTitle": "Nouvelle version @version disponible", "newVersionTitle": "Nouvelle version @version disponible",
"newVersionUpdate": "Mettre à jour maintenant", "newVersionUpdate": "Mettre à jour",
"newVersionLater": "Plus tard", "newVersionLater": "Plus tard",
"extensions": "Extensions", "extensions": "Extensions",
"extensionInstallUrl": "URL d'installation", "extensionInstallUrl": "URL d'installation",
@@ -164,6 +164,11 @@
"autoExtract": "Extraire automatiquement les archives", "autoExtract": "Extraire automatiquement les archives",
"archivePassword": "Mot de passe de l'archive", "archivePassword": "Mot de passe de l'archive",
"archivePasswordHint": "Laisser vide si non protégé par mot de passe", "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", "deleteAfterExtract": "Supprimer l'archive après extraction",
"extracting": "Extraction en cours", "extracting": "Extraction en cours",
"extractDone": "Extraction terminée", "extractDone": "Extraction terminée",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Hozzáad", "add": "Hozzáad",
"edit": "Szerk", "edit": "Szerk",
"newVersionTitle": "Új verzió felfedezése @version", "newVersionTitle": "Új verzió felfedezése @version",
"newVersionUpdate": "Frissítés most", "newVersionUpdate": "Frissítés",
"newVersionLater": "Később", "newVersionLater": "Később",
"extensions": "Bővítmények", "extensions": "Bővítmények",
"extensionInstallUrl": "Telepítési URL", "extensionInstallUrl": "Telepítési URL",
@@ -164,6 +164,11 @@
"autoExtract": "Archívumok automatikus kicsomagolása", "autoExtract": "Archívumok automatikus kicsomagolása",
"archivePassword": "Archívum jelszó", "archivePassword": "Archívum jelszó",
"archivePasswordHint": "Hagyja üresen, ha nincs jelszóval védve", "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", "deleteAfterExtract": "Archívum törlése kicsomagolás után",
"extracting": "Kicsomagolás", "extracting": "Kicsomagolás",
"extractDone": "Kicsomagolás befejezve", "extractDone": "Kicsomagolás befejezve",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Tambah", "add": "Tambah",
"edit": "Sunting", "edit": "Sunting",
"newVersionTitle": "Temukan versi batu @version", "newVersionTitle": "Temukan versi batu @version",
"newVersionUpdate": "Perbarui Sekarang", "newVersionUpdate": "Perbarui",
"newVersionLater": "Nanti", "newVersionLater": "Nanti",
"extensions": "Ekstensi", "extensions": "Ekstensi",
"extensionInstallUrl": "URL Instal", "extensionInstallUrl": "URL Instal",
@@ -164,6 +164,11 @@
"autoExtract": "Ekstrak Arsip Otomatis", "autoExtract": "Ekstrak Arsip Otomatis",
"archivePassword": "Kata Sandi Arsip", "archivePassword": "Kata Sandi Arsip",
"archivePasswordHint": "Kosongkan jika tidak dilindungi kata sandi", "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", "deleteAfterExtract": "Hapus arsip setelah ekstraksi",
"extracting": "Mengekstrak", "extracting": "Mengekstrak",
"extractDone": "Ekstraksi selesai", "extractDone": "Ekstraksi selesai",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Aggiungi", "add": "Aggiungi",
"edit": "Modifica", "edit": "Modifica",
"newVersionTitle": "Scopri la nuova versione @version", "newVersionTitle": "Scopri la nuova versione @version",
"newVersionUpdate": "Aggiorna ora", "newVersionUpdate": "Aggiorna",
"newVersionLater": "Dopo", "newVersionLater": "Dopo",
"extensions": "Estensioni", "extensions": "Estensioni",
"extensionInstallUrl": "Installa URL", "extensionInstallUrl": "Installa URL",
@@ -164,6 +164,11 @@
"autoExtract": "Estrai automaticamente gli archivi", "autoExtract": "Estrai automaticamente gli archivi",
"archivePassword": "Password archivio", "archivePassword": "Password archivio",
"archivePasswordHint": "Lasciare vuoto se non protetto da password", "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", "deleteAfterExtract": "Elimina l'archivio dopo l'estrazione",
"extracting": "Estrazione in corso", "extracting": "Estrazione in corso",
"extractDone": "Estrazione completata", "extractDone": "Estrazione completata",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "追加", "add": "追加",
"edit": "編集", "edit": "編集",
"newVersionTitle": "新しいバージョン @version を発見する", "newVersionTitle": "新しいバージョン @version を発見する",
"newVersionUpdate": "アップデート", "newVersionUpdate": "更新",
"newVersionLater": "後で", "newVersionLater": "後で",
"extensions": "拡張機能", "extensions": "拡張機能",
"extensionInstallUrl": "インストールURL", "extensionInstallUrl": "インストールURL",
@@ -164,6 +164,11 @@
"autoExtract": "アーカイブを自動展開", "autoExtract": "アーカイブを自動展開",
"archivePassword": "アーカイブパスワード", "archivePassword": "アーカイブパスワード",
"archivePasswordHint": "パスワードなしの場合は空のままにしてください", "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": "展開後にアーカイブを削除", "deleteAfterExtract": "展開後にアーカイブを削除",
"extracting": "展開中", "extracting": "展開中",
"extractDone": "展開完了", "extractDone": "展開完了",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "추가", "add": "추가",
"edit": "편집", "edit": "편집",
"newVersionTitle": "새 버전 @version 발견", "newVersionTitle": "새 버전 @version 발견",
"newVersionUpdate": "지금 업데이트", "newVersionUpdate": "업데이트",
"newVersionLater": "나중에", "newVersionLater": "나중에",
"extensions": "확장 프로그램", "extensions": "확장 프로그램",
"extensionInstallUrl": "설치 URL", "extensionInstallUrl": "설치 URL",
@@ -164,6 +164,11 @@
"autoExtract": "압축 파일 자동 압축 해제", "autoExtract": "압축 파일 자동 압축 해제",
"archivePassword": "압축 파일 비밀번호", "archivePassword": "압축 파일 비밀번호",
"archivePasswordHint": "비밀번호가 없으면 비워 두세요", "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": "압축 해제 후 압축 파일 삭제", "deleteAfterExtract": "압축 해제 후 압축 파일 삭제",
"extracting": "압축 해제 중", "extracting": "압축 해제 중",
"extractDone": "압축 해제 완료", "extractDone": "압축 해제 완료",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Dodaj", "add": "Dodaj",
"edit": "Edycja", "edit": "Edycja",
"newVersionTitle": "Sprawdź aktualizację @version", "newVersionTitle": "Sprawdź aktualizację @version",
"newVersionUpdate": "Aktualizuj teraz", "newVersionUpdate": "Aktualizuj",
"newVersionLater": "Później", "newVersionLater": "Później",
"extensions": "Wtyczki", "extensions": "Wtyczki",
"extensionInstallUrl": "Instaluj z url", "extensionInstallUrl": "Instaluj z url",
@@ -164,6 +164,11 @@
"autoExtract": "Automatycznie wyodrębnij archiwa", "autoExtract": "Automatycznie wyodrębnij archiwa",
"archivePassword": "Hasło archiwum", "archivePassword": "Hasło archiwum",
"archivePasswordHint": "Pozostaw puste, jeśli nie ma hasła", "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", "deleteAfterExtract": "Usuń archiwum po rozpakowaniu",
"extracting": "Rozpakowywanie", "extracting": "Rozpakowywanie",
"extractDone": "Rozpakowywanie zakończone", "extractDone": "Rozpakowywanie zakończone",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Adicionar", "add": "Adicionar",
"edit": "Editar", "edit": "Editar",
"newVersionTitle": "Nova Versão Disponível @version", "newVersionTitle": "Nova Versão Disponível @version",
"newVersionUpdate": "Atualizar Agora", "newVersionUpdate": "Atualizar",
"newVersionLater": "Mais Tarde", "newVersionLater": "Mais Tarde",
"extensions": "Extensões", "extensions": "Extensões",
"extensionInstallUrl": "Link de Instalação", "extensionInstallUrl": "Link de Instalação",
@@ -164,6 +164,11 @@
"autoExtract": "Extrair arquivos automaticamente", "autoExtract": "Extrair arquivos automaticamente",
"archivePassword": "Senha do arquivo", "archivePassword": "Senha do arquivo",
"archivePasswordHint": "Deixar vazio se não estiver protegido por senha", "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", "deleteAfterExtract": "Excluir arquivo após extração",
"extracting": "Extraindo", "extracting": "Extraindo",
"extractDone": "Extração concluída", "extractDone": "Extração concluída",
+5
View File
@@ -164,6 +164,11 @@
"autoExtract": "Автоматически распаковать архивы", "autoExtract": "Автоматически распаковать архивы",
"archivePassword": "Пароль архива", "archivePassword": "Пароль архива",
"archivePasswordHint": "Оставить пустым, если архив не защищён паролем", "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": "Удалить архив после распаковки", "deleteAfterExtract": "Удалить архив после распаковки",
"extracting": "Распаковка", "extracting": "Распаковка",
"extractDone": "Распаковка завершена", "extractDone": "Распаковка завершена",
+5
View File
@@ -178,6 +178,11 @@
"autoExtract": "Automaticky rozbaliť archívy", "autoExtract": "Automaticky rozbaliť archívy",
"archivePassword": "Heslo archívu", "archivePassword": "Heslo archívu",
"archivePasswordHint": "Nechajte prázdne, ak nie je chránený heslom", "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", "deleteAfterExtract": "Po rozbalení odstrániť archívy",
"extracting": "Rozbaľovanie", "extracting": "Rozbaľovanie",
"extractDone": "Rozbalenie dokončené", "extractDone": "Rozbalenie dokončené",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "சேர்", "add": "சேர்",
"edit": "திருத்துக", "edit": "திருத்துக",
"newVersionTitle": "புதிய பதிப்பைக் கண்டறியவும் @version", "newVersionTitle": "புதிய பதிப்பைக் கண்டறியவும் @version",
"newVersionUpdate": "இப்பொழுது புதுப்பி", "newVersionUpdate": "புதுப்பிப்பு",
"newVersionLater": "பின்னர்", "newVersionLater": "பின்னர்",
"extensions": "நீட்டிப்புகள்", "extensions": "நீட்டிப்புகள்",
"extensionInstallUrl": "நிறுவப்படும் URL", "extensionInstallUrl": "நிறுவப்படும் URL",
@@ -164,6 +164,11 @@
"autoExtract": "காப்பகங்களை தானாக பிரித்தெடு", "autoExtract": "காப்பகங்களை தானாக பிரித்தெடு",
"archivePassword": "காப்பக கடவுச்சொல்", "archivePassword": "காப்பக கடவுச்சொல்",
"archivePasswordHint": "கடவுச்சொல் இல்லையென்றால் காலியாக விடவும்", "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": "பிரித்தெடுத்த பிறகு காப்பகத்தை நீக்கு", "deleteAfterExtract": "பிரித்தெடுத்த பிறகு காப்பகத்தை நீக்கு",
"extracting": "பிரித்தெடுக்கிறது", "extracting": "பிரித்தெடுக்கிறது",
"extractDone": "பிரித்தெடுத்தல் முடிந்தது", "extractDone": "பிரித்தெடுத்தல் முடிந்தது",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Ekle", "add": "Ekle",
"edit": "Düzenle", "edit": "Düzenle",
"newVersionTitle": "Yeni sürüm @version keşfedildi", "newVersionTitle": "Yeni sürüm @version keşfedildi",
"newVersionUpdate": "Şimdi Güncelle", "newVersionUpdate": "Güncelle",
"newVersionLater": "Daha Sonra", "newVersionLater": "Daha Sonra",
"extensions": "Eklentiler", "extensions": "Eklentiler",
"extensionInstallUrl": "Kurulum URL'si", "extensionInstallUrl": "Kurulum URL'si",
@@ -164,6 +164,11 @@
"autoExtract": "Arşivleri Otomatik Çıkar", "autoExtract": "Arşivleri Otomatik Çıkar",
"archivePassword": "Arşiv Şifresi", "archivePassword": "Arşiv Şifresi",
"archivePasswordHint": "Şifre korumalı değilse boş bırakın", "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", "deleteAfterExtract": "Çıkardıktan sonra arşivi sil",
"extracting": "Çıkarılıyor", "extracting": "Çıkarılıyor",
"extractDone": "Çıkarma tamamlandı", "extractDone": "Çıkarma tamamlandı",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Додати", "add": "Додати",
"edit": "Редагування", "edit": "Редагування",
"newVersionTitle": "Доступна нова версія @Version", "newVersionTitle": "Доступна нова версія @Version",
"newVersionUpdate": "Оновити зараз", "newVersionUpdate": "Оновити",
"newVersionLater": "Пізніше", "newVersionLater": "Пізніше",
"extensions": "Розширення", "extensions": "Розширення",
"extensionInstallUrl": "URL для встановлення", "extensionInstallUrl": "URL для встановлення",
@@ -164,6 +164,11 @@
"autoExtract": "Автоматично розпаковувати архіви", "autoExtract": "Автоматично розпаковувати архіви",
"archivePassword": "Пароль архіву", "archivePassword": "Пароль архіву",
"archivePasswordHint": "Залишити порожнім, якщо архів не захищений паролем", "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": "Видалити архів після розпакування", "deleteAfterExtract": "Видалити архів після розпакування",
"extracting": "Розпакування", "extracting": "Розпакування",
"extractDone": "Розпакування завершено", "extractDone": "Розпакування завершено",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Thêm", "add": "Thêm",
"edit": "Sửa", "edit": "Sửa",
"newVersionTitle": "Khám phá phiên bản mới @version", "newVersionTitle": "Khám phá phiên bản mới @version",
"newVersionUpdate": "Cập nhật ngay", "newVersionUpdate": "Cập nhật",
"newVersionLater": "Sau", "newVersionLater": "Sau",
"extensions": "Tiện ích mở rộng", "extensions": "Tiện ích mở rộng",
"extensionInstallUrl": "Liên kết cài đặt", "extensionInstallUrl": "Liên kết cài đặt",
@@ -164,6 +164,11 @@
"autoExtract": "Tự động giải nén tập tin nén", "autoExtract": "Tự động giải nén tập tin nén",
"archivePassword": "Mật khẩu 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", "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", "deleteAfterExtract": "Xóa tập tin nén sau khi giải nén",
"extracting": "Đang giải nén", "extracting": "Đang giải nén",
"extractDone": "Giải nén hoàn tất", "extractDone": "Giải nén hoàn tất",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "添加", "add": "添加",
"edit": "编辑", "edit": "编辑",
"newVersionTitle": "发现新版本 @version", "newVersionTitle": "发现新版本 @version",
"newVersionUpdate": "立即更新", "newVersionUpdate": "更新",
"newVersionLater": "稍后再说", "newVersionLater": "稍后再说",
"extensions": "扩展", "extensions": "扩展",
"extensionInstallUrl": "安装链接", "extensionInstallUrl": "安装链接",
@@ -164,6 +164,11 @@
"autoExtract": "自动解压压缩包", "autoExtract": "自动解压压缩包",
"archivePassword": "压缩包密码", "archivePassword": "压缩包密码",
"archivePasswordHint": "如无密码请留空", "archivePasswordHint": "如无密码请留空",
"checksumAlgorithm": "校验和算法",
"checksumHash": "校验和",
"checksumExpectedHint": "预期哈希值(可选)",
"checksumInvalidLength": "{algorithm} 校验和哈希长度无效:应为 {length} 个字符",
"checksumInvalidHex": "校验和哈希无效:应为十六进制字符(0-9,a-f)",
"deleteAfterExtract": "解压后删除压缩包", "deleteAfterExtract": "解压后删除压缩包",
"extracting": "正在解压", "extracting": "正在解压",
"extractDone": "解压完成", "extractDone": "解压完成",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "新增", "add": "新增",
"edit": "編輯", "edit": "編輯",
"newVersionTitle": "發現新版本 @version", "newVersionTitle": "發現新版本 @version",
"newVersionUpdate": "立即更新", "newVersionUpdate": "更新",
"newVersionLater": "稍後", "newVersionLater": "稍後",
"extensions": "擴充功能", "extensions": "擴充功能",
"extensionInstallUrl": "安裝 URL", "extensionInstallUrl": "安裝 URL",
@@ -164,6 +164,11 @@
"autoExtract": "自動解壓縮壓縮包", "autoExtract": "自動解壓縮壓縮包",
"archivePassword": "壓縮包密碼", "archivePassword": "壓縮包密碼",
"archivePasswordHint": "如無密碼請留空", "archivePasswordHint": "如無密碼請留空",
"checksumAlgorithm": "校驗和演算法",
"checksumHash": "校驗和",
"checksumExpectedHint": "預期雜湊值(可選)",
"checksumInvalidLength": "{algorithm} 校驗和雜湊長度無效:應為 {length} 個字元",
"checksumInvalidHex": "校驗和雜湊無效:應為十六進位字元(0-9,a-f)",
"deleteAfterExtract": "解壓後刪除壓縮檔", "deleteAfterExtract": "解壓後刪除壓縮檔",
"extracting": "正在解壓", "extracting": "正在解壓",
"extractDone": "解壓完成", "extractDone": "解壓完成",
@@ -16,6 +16,7 @@ class AppDetailDrawer extends StatelessWidget {
required this.child, required this.child,
this.drawerKey, this.drawerKey,
this.width, this.width,
this.titleTrailing,
}); });
final bool open; final bool open;
@@ -24,6 +25,7 @@ class AppDetailDrawer extends StatelessWidget {
final Widget child; final Widget child;
final Key? drawerKey; final Key? drawerKey;
final double? width; final double? width;
final Widget? titleTrailing;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -82,15 +84,25 @@ class AppDetailDrawer extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Text( child: Row(
title, children: [
maxLines: 1, Flexible(
overflow: TextOverflow.ellipsis, child: Text(
style: TextStyle( title,
color: palette.textPrimary, maxLines: 1,
fontSize: 16, overflow: TextOverflow.ellipsis,
fontWeight: FontWeight.w700, style: TextStyle(
), color: palette.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
if (titleTrailing != null) ...[
const SizedBox(width: 8),
Flexible(child: titleTrailing!),
],
],
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -13,12 +13,14 @@ class AppDetailPage extends StatelessWidget {
required this.onBack, required this.onBack,
required this.child, required this.child,
this.backgroundColor, this.backgroundColor,
this.titleTrailing,
}); });
final String title; final String title;
final VoidCallback onBack; final VoidCallback onBack;
final Widget child; final Widget child;
final Color? backgroundColor; final Color? backgroundColor;
final Widget? titleTrailing;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -53,12 +55,22 @@ class AppDetailPage extends StatelessWidget {
), ),
), ),
Expanded( Expanded(
child: Text( child: Row(
title, mainAxisAlignment: MainAxisAlignment.center,
textAlign: TextAlign.center, children: [
maxLines: 1, Flexible(
overflow: TextOverflow.ellipsis, child: Text(
style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700), title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700),
),
),
if (titleTrailing != null) ...[
const SizedBox(width: 8),
Flexible(child: titleTrailing!),
],
],
), ),
), ),
const SizedBox(width: 44), const SizedBox(width: 44),
@@ -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 [],
);
@@ -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/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_loading_button.dart'; import 'package:gopeed/shared/widgets/app_loading_button.dart';
import 'package:gopeed/shared/widgets/app_tooltip.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; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() { void main() {
@@ -611,6 +612,355 @@ void main() {
expect(find.byKey(const ValueKey('create-history-clear')), findsOneWidget); expect(find.byKey(const ValueKey('create-history-clear')), findsOneWidget);
expect(find.byKey(const ValueKey('create-history-close')), 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) { String _fieldText(WidgetTester tester, String key) {
+29 -2
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/application/extensions_controller.dart';
import 'package:gopeed/features/extensions/presentation/pages/extension_details_page.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/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/home/presentation/widgets/primary_rail.dart';
import 'package:gopeed/features/tasks/application/pending_update_task.dart'; import 'package:gopeed/features/tasks/application/pending_update_task.dart';
import 'package:gopeed/features/tasks/application/task_batch_selection_controller.dart'; import 'package:gopeed/features/tasks/application/task_batch_selection_controller.dart';
@@ -688,6 +689,30 @@ void main() {
expect(tester.takeException(), isNull); 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 { testWidgets('extension details reuse the desktop detail drawer', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1100, 900)); await _setTestSize(tester, const Size(1100, 900));
final controller = FakeExtensionsController(); final controller = FakeExtensionsController();
@@ -817,7 +842,9 @@ void main() {
await tester.tap(find.byKey(const ValueKey('extension-card-extension-0'))); await tester.tap(find.byKey(const ValueKey('extension-card-extension-0')));
await tester.pumpAndSettle(); 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 dialog = find.byKey(const ValueKey('extension-update-dialog'));
final confirm = find.byKey(const ValueKey('confirm-update-extension-button')); final confirm = find.byKey(const ValueKey('confirm-update-extension-button'));
final cancel = find.byKey(const ValueKey('cancel-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)); final progress = find.descendant(of: updateButton, matching: find.byType(shad.CircularProgressIndicator));
expect(progress, findsOneWidget); expect(progress, findsOneWidget);
expect(tester.getSize(progress), const Size.square(14)); 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(); runtimeController.completeTrackerUpdate();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
+11
View File
@@ -57,6 +57,17 @@ add_subdirectory("runner")
# them to the application. # them to the application.
include(flutter/generated_plugins.cmake) 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 === # === Installation ===
# Support files are copied into place next to the executable, so that it can # Support files are copied into place next to the executable, so that it can