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 {
url string
connections *int
dir *string
url string
connections *int
dir *string
checksum *string
checksumAlgo *string
}
func parse() *args {
@@ -20,6 +22,8 @@ func parse() *args {
var args args
args.connections = flag.Int("C", 16, "Concurrent connections.")
args.dir = flag.String("D", dir, "Store directory.")
args.checksum = flag.String("checksum", "", "Expected checksum hash (hex encoded).")
args.checksumAlgo = flag.String("checksum-algo", "md5", "Checksum algorithm: md5, sha1, or sha256.")
flag.Parse()
t := flag.Args()
if len(t) > 0 {
+17 -2
View File
@@ -19,6 +19,18 @@ func main() {
var wg sync.WaitGroup
wg.Add(1)
var checksumOpt *http.ChecksumOption
if args.checksum != nil && *args.checksum != "" {
algo := "md5"
if args.checksumAlgo != nil && *args.checksumAlgo != "" {
algo = *args.checksumAlgo
}
checksumOpt = &http.ChecksumOption{
Algorithm: algo,
Expected: *args.checksum,
}
}
_, err := download.Boot().
URL(args.url).
Listener(func(event *download.Event) {
@@ -43,8 +55,11 @@ func main() {
}
}).
Create(&base.Options{
Path: *args.dir,
Extra: http.OptsExtra{Connections: *args.connections},
Path: *args.dir,
Extra: http.OptsExtra{
Connections: *args.connections,
Checksum: checksumOpt,
},
})
if err != nil {
panic(err)
+111 -10
View File
@@ -2,8 +2,13 @@ package http
import (
"context"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"hash"
"io"
"net/http"
"net/url"
@@ -280,6 +285,12 @@ type Fetcher struct {
primaryReadyOnce sync.Once
primaryReadyCh chan struct{}
// checksumFailed marks that the last completed download failed
// checksum verification, so the next retry must force a full
// re-download instead of skipping connections that already
// reported Completed.
checksumFailed atomic.Bool
// Start pending mechanism
startPending atomic.Bool
resolvedCh chan struct{} // Signal when resolve completes
@@ -609,7 +620,7 @@ func (f *Fetcher) stopPrefetchAndCopyData() int64 {
}
// Wait for prefetch to finish (with timeout)
for i := 0; i < 1000 && !f.prefetchDone.Load(); i++ {
for i := 0; f.prefetchStopCh != nil && i < 1000 && !f.prefetchDone.Load(); i++ {
time.Sleep(10 * time.Millisecond)
}
@@ -664,6 +675,22 @@ func (f *Fetcher) cleanupPrefetchFile() {
f.resolveFallback.Store(false)
}
// prepareFullRedownload resets all fetcher connection and prefetch state
// after a checksum mismatch so the subsequent Start takes the fresh-start path.
func (f *Fetcher) prepareFullRedownload() {
f.connMu.Lock()
if f.meta.Res.Range {
f.connections = nil
f.resolveConn = nil
f.rangeReprobeEligible = false
f.rangeValidatorPinned = false
}
f.connMu.Unlock()
f.cleanupPrefetchFile()
f.resolveDataPos.Store(0)
}
// Options may be replaced between Resolve and Start when creating a resolved task.
func (f *Fetcher) initOptions() error {
opts := f.meta.Opts
@@ -681,6 +708,9 @@ func (f *Fetcher) initOptions() error {
extra.Connections = 1
}
}
if err := extra.Checksum.Validate(); err != nil {
return err
}
return nil
}
@@ -735,11 +765,26 @@ func (f *Fetcher) doStart() error {
// If retrying after error, reset connection states for retry
if state == stateError {
fullRedownload := f.checksumFailed.Swap(false)
if fullRedownload {
// Checksum mismatch: the file content itself is wrong even
// though every connection reported Completed. Truncate the
// corrupt file so stale bytes cannot linger if the retry is
// interrupted before every connection re-writes its chunk.
if err := os.Truncate(f.meta.SingleFilepath(), 0); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to reset file after checksum mismatch: %w", err)
}
f.prepareFullRedownload()
}
f.connMu.Lock()
for _, conn := range f.connections {
// Reset connections that can be retried
if !conn.Completed && conn.State != connCompleted {
if !f.hasSequentialPrefixLocked(conn) {
// On a normal error, only reset connections that didn't
// finish. On a checksum mismatch, reset EVERY connection —
// "Completed" only means the download loop finished, not
// that the bytes were verified correct.
shouldReset := fullRedownload || (!conn.Completed && conn.State != connCompleted)
if shouldReset {
if fullRedownload || !f.hasSequentialPrefixLocked(conn) {
f.resetConnectionForRestart(conn)
}
conn.State = connNotStarted
@@ -970,8 +1015,14 @@ func (f *Fetcher) expandConnections() {
}
f.fileMu.Unlock()
f.setState(stateDone)
f.doneCh <- nil
var err error
if chkErr := f.verifyChecksum(); chkErr != nil {
err = chkErr
f.setState(stateError)
} else {
f.setState(stateDone)
}
f.doneCh <- err
return
}
@@ -2067,7 +2118,7 @@ func (f *Fetcher) resetConnectionForRestart(conn *connection) {
// so pause/retry must restart instead of continuing from the old offset.
if conn.Chunk == nil {
conn.Chunk = newChunk(0, 0)
} else {
} else if !f.meta.Res.Range {
conn.Chunk.Begin = 0
conn.Chunk.setEnd(0)
conn.Chunk.Downloaded = 0
@@ -2238,10 +2289,15 @@ func (f *Fetcher) onDownloadComplete() {
}
f.fileMu.Unlock()
if finalErr != nil {
f.setState(stateError)
if finalErr == nil {
if chkErr := f.verifyChecksum(); chkErr != nil {
finalErr = chkErr
f.setState(stateError)
} else {
f.setState(stateDone)
}
} else {
f.setState(stateDone)
f.setState(stateError)
}
select {
@@ -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 {
// Check if all data has been downloaded
f.connMu.Lock()
@@ -0,0 +1,388 @@
package http
import (
"crypto/sha256"
"encoding/hex"
"fmt"
gohttp "net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
)
// serveRangeContent serves byte range requests or full responses from data.
func serveRangeContent(w gohttp.ResponseWriter, r *gohttp.Request, data []byte) {
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
rangeHdr := r.Header.Get(base.HttpHeaderRange)
total := int64(len(data))
if rangeHdr == "" {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", total))
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(data)
return
}
// Parse bytes=start-end or bytes=start-
if !strings.HasPrefix(rangeHdr, "bytes=") {
w.WriteHeader(gohttp.StatusBadRequest)
return
}
spec := strings.TrimPrefix(rangeHdr, "bytes=")
parts := strings.Split(spec, "-")
start, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || start < 0 || start >= total {
w.WriteHeader(gohttp.StatusRequestedRangeNotSatisfiable)
return
}
end := total - 1
if len(parts) > 1 && parts[1] != "" {
if parsedEnd, err := strconv.ParseInt(parts[1], 10, 64); err == nil && parsedEnd >= start && parsedEnd < total {
end = parsedEnd
}
}
contentLength := end - start + 1
w.Header().Set(base.HttpHeaderContentRange, fmt.Sprintf("bytes %d-%d/%d", start, end, total))
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", contentLength))
w.WriteHeader(gohttp.StatusPartialContent)
_, _ = w.Write(data[start : end+1])
}
func TestFetcher_ChecksumMismatch_Retry(t *testing.T) {
corruptContent := []byte("Corrupt Initial Download Data 33B")
correctContent := []byte("Correct Retried Download Data 33B")
correctSha256Hasher := sha256.New()
correctSha256Hasher.Write(correctContent)
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
var phase atomic.Int32 // 0: serve corruptContent, 1: serve correctContent
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
var served []byte
if phase.Load() == 0 {
served = corruptContent
} else {
served = correctContent
}
serveRangeContent(w, r, served)
}))
defer server.Close()
downloadDir := t.TempDir()
fileName := "test_checksum_retry.txt"
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: downloadDir,
Name: fileName,
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: correctSha256,
},
},
}
// 1. Initial attempt -> will download corruptContent, failing checksum verification
if err := f.Resolve(&base.Request{URL: server.URL + "/retry_test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
err := f.Wait()
if err == nil {
t.Fatal("expected checksum mismatch error on initial attempt, got nil")
}
if !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
}
if f.getState() != stateError {
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
}
// Flip phase to 1 after observing checksum mismatch
phase.Store(1)
// 2. Retry download -> should trigger forced full reset, truncate file, and re-download correctContent
if err := f.Start(); err != nil {
t.Fatalf("Start error on retry: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error on retry (expected success): %v", err)
}
if f.getState() != stateDone {
t.Fatalf("expected stateDone after successful retry, got %v", f.getState())
}
// 3. Verify on-disk file content matches the re-downloaded bytes
filePath := f.meta.SingleFilepath()
fileBytes, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read completed file: %v", err)
}
if string(fileBytes) != string(correctContent) {
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
}
// 4. Compute hash independently and verify
actualHasher := sha256.New()
actualHasher.Write(fileBytes)
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
if actualHash != correctSha256 {
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
}
}
func TestFetcher_ChecksumMismatch_Retry_PartialPrefetch(t *testing.T) {
corruptContent := []byte("Corrupt Initial Download Data 33B")
correctContent := []byte("Correct Retried Download Data 33B")
correctSha256Hasher := sha256.New()
correctSha256Hasher.Write(correctContent)
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
var phase atomic.Int32 // 0: corrupt, 1: correct
var isFirstRequest atomic.Bool
isFirstRequest.Store(true)
releasePrefetch := make(chan struct{})
var closeReleaseOnce sync.Once
var mu sync.Mutex
var phase1Ranges []string
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
curPhase := phase.Load()
if curPhase == 1 {
mu.Lock()
phase1Ranges = append(phase1Ranges, r.Header.Get(base.HttpHeaderRange))
mu.Unlock()
serveRangeContent(w, r, correctContent)
return
}
// Phase 0: First request is Resolve GET without Range header.
// Write exactly 7 bytes and flush, then block until release channel closes or context is done.
if isFirstRequest.CompareAndSwap(true, false) {
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(corruptContent)))
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(corruptContent[:7])
if flusher, ok := w.(gohttp.Flusher); ok {
flusher.Flush()
}
select {
case <-releasePrefetch:
case <-r.Context().Done():
}
return
}
// Subsequent requests in Phase 0 (such as Range request from Start)
serveRangeContent(w, r, corruptContent)
}))
defer server.Close()
defer closeReleaseOnce.Do(func() { close(releasePrefetch) })
downloadDir := t.TempDir()
fileName := "test_checksum_prefetch_retry.txt"
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: downloadDir,
Name: fileName,
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: correctSha256,
},
},
}
// 1. Resolve phase
if err := f.Resolve(&base.Request{URL: server.URL + "/prefetch_retry.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
// Wait until prefetch has received exactly 7 bytes
for i := 0; i < 200; i++ {
if f.prefetchSize.Load() >= 7 {
break
}
time.Sleep(10 * time.Millisecond)
}
if f.prefetchSize.Load() < 7 {
t.Fatalf("expected at least 7 prefetched bytes, got %d", f.prefetchSize.Load())
}
// Release prefetch handler right before Start
closeReleaseOnce.Do(func() { close(releasePrefetch) })
// 2. Start initial download attempt (will complete with corruptContent and fail checksum)
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
err := f.Wait()
if err == nil {
t.Fatal("expected checksum mismatch error on initial attempt, got nil")
}
if !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
}
if f.getState() != stateError {
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
}
// 3. Flip to phase 1 (correctContent) and retry
phase.Store(1)
if err := f.Start(); err != nil {
t.Fatalf("Start error on retry: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error on retry (expected success): %v", err)
}
if f.getState() != stateDone {
t.Fatalf("expected stateDone after successful retry, got %v", f.getState())
}
// Verify that in Phase 1 at least one Range request started at byte 0
mu.Lock()
rangesSeen := append([]string(nil), phase1Ranges...)
mu.Unlock()
hasByteZero := false
for _, r := range rangesSeen {
if strings.HasPrefix(r, "bytes=0-") {
hasByteZero = true
break
}
}
if !hasByteZero {
t.Fatalf("expected at least one Phase 1 Range request starting at byte 0, saw: %v", rangesSeen)
}
// Verify on-disk content matches correctContent exactly
filePath := f.meta.SingleFilepath()
fileBytes, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read completed file: %v", err)
}
if string(fileBytes) != string(correctContent) {
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
}
// Compute hash independently and verify
actualHasher := sha256.New()
actualHasher.Write(fileBytes)
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
if actualHash != correctSha256 {
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
}
}
func TestFetcher_ChecksumMismatch_Retry_NonRange(t *testing.T) {
corruptContent := []byte("Corrupt Non-Range Initial Data 33B")
correctContent := []byte("Correct Non-Range Retried Data 33B")
correctSha256Hasher := sha256.New()
correctSha256Hasher.Write(correctContent)
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
var phase atomic.Int32 // 0: corrupt, 1: correct
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
// Server does not support Range (always returns full content 200 OK)
var served []byte
if phase.Load() == 0 {
served = corruptContent
} else {
served = correctContent
}
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(served)))
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(served)
}))
defer server.Close()
downloadDir := t.TempDir()
fileName := "test_checksum_nonrange_retry.txt"
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: downloadDir,
Name: fileName,
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: correctSha256,
},
},
}
// 1. Resolve and start initial attempt
if err := f.Resolve(&base.Request{URL: server.URL + "/nonrange_retry.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
err := f.Wait()
if err == nil {
t.Fatal("expected checksum mismatch error on initial non-range attempt, got nil")
}
if !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
}
if f.getState() != stateError {
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
}
// 2. Switch phase and retry
phase.Store(1)
if err := f.Start(); err != nil {
t.Fatalf("Start error on non-range retry: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error on non-range retry (expected success): %v", err)
}
if f.getState() != stateDone {
t.Fatalf("expected stateDone after successful non-range retry, got %v", f.getState())
}
// 3. Verify on-disk file content
filePath := f.meta.SingleFilepath()
fileBytes, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read completed file: %v", err)
}
if string(fileBytes) != string(correctContent) {
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
}
// 4. Compute hash independently and verify
actualHasher := sha256.New()
actualHasher.Write(fileBytes)
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
if actualHash != correctSha256 {
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
}
}
@@ -0,0 +1,304 @@
package http
import (
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"fmt"
gohttp "net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
)
func TestFetcher_ChecksumVerification(t *testing.T) {
content := []byte("Hello, Gopeed Checksum Verification Test Data!")
md5Hasher := md5.New()
md5Hasher.Write(content)
correctMd5 := hex.EncodeToString(md5Hasher.Sum(nil))
sha256Hasher := sha256.New()
sha256Hasher.Write(content)
correctSha256 := hex.EncodeToString(sha256Hasher.Sum(nil))
wrongHash := "0123456789abcdef0123456789abcdef"
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(content)))
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(content)
}))
defer server.Close()
t.Run("Valid MD5", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_valid_md5.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "md5",
Expected: correctMd5,
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected success): %v", err)
}
})
t.Run("Valid SHA256", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_valid_sha256.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: correctSha256,
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected success): %v", err)
}
})
t.Run("Wrong MD5 mismatch", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_wrong_md5.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "md5",
Expected: wrongHash,
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
err := f.Wait()
if err == nil {
t.Fatal("Wait expected checksum mismatch error, got nil")
}
if !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
}
})
t.Run("Unsupported Algorithm", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_unsupported.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "invalid-algo",
Expected: wrongHash,
},
},
}
err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts)
if err == nil {
t.Fatal("Resolve expected error for unsupported algorithm, got nil")
}
if !strings.Contains(err.Error(), "unsupported checksum algorithm") {
t.Fatalf("expected 'unsupported checksum algorithm' in error, got %v", err)
}
if f.checksumFailed.Load() {
t.Fatal("checksumFailed flag should NOT be set on unsupported algorithm error")
}
})
t.Run("Case-Insensitive Hash Comparison", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_case_insensitive.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: strings.ToUpper(correctSha256),
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected success with upper case hash): %v", err)
}
})
t.Run("Empty Expected Checksum (Skipped)", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_empty_expected.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "",
Expected: "",
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected skip and success): %v", err)
}
})
t.Run("Large File Multi-Chunk Streaming (>64KB)", func(t *testing.T) {
largeData := make([]byte, 128*1024)
for i := range largeData {
largeData[i] = byte(i % 256)
}
largeSha256Hasher := sha256.New()
largeSha256Hasher.Write(largeData)
largeSha256 := hex.EncodeToString(largeSha256Hasher.Sum(nil))
largeServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(largeData)))
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(largeData)
}))
defer largeServer.Close()
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_large_file.bin",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: largeSha256,
},
},
}
if err := f.Resolve(&base.Request{URL: largeServer.URL + "/large.bin"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected large file checksum success): %v", err)
}
})
t.Run("No Checksum (Opt-in backward compatibility)", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: test.DownloadName,
Extra: &http.OptsExtra{
Connections: 1,
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error: %v", err)
}
})
t.Run("Invalid Checksum In Resolve Makes Zero Requests", func(t *testing.T) {
var reqReceived atomic.Int32
customServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
reqReceived.Add(1)
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write([]byte("some data"))
}))
defer customServer.Close()
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_invalid_checksum.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: "invalid-hex-chars-not-valid-hex-00000000000000000000000000000000",
},
},
}
err := f.Resolve(&base.Request{URL: customServer.URL + "/test.txt"}, opts)
if err == nil {
t.Fatal("Resolve expected error for invalid checksum hash, got nil")
}
if reqReceived.Load() != 0 {
t.Fatalf("expected 0 requests to server on invalid checksum in Resolve, got %d", reqReceived.Load())
}
})
}
+13
View File
@@ -13,6 +13,7 @@ import (
"net/url"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -853,22 +854,34 @@ func TestFetcher_InitialIgnoredRangeUsesResponseSize(t *testing.T) {
resolvePayload := bytes.Repeat([]byte("resolve"), 64*1024)
replacement := bytes.Repeat([]byte("replacement"), 24*1024)
var rangeRequests atomic.Int32
rangeStarted := make(chan struct{})
releaseResolve := sync.OnceFunc(func() { close(rangeStarted) })
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
if r.Header.Get(base.HttpHeaderRange) == "" {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(resolvePayload)))
w.WriteHeader(gohttp.StatusOK)
w.(gohttp.Flusher).Flush()
// Keep Resolve prefetch incomplete until the Range request starts.
// Otherwise Start may reuse the completed body and skip this scenario.
select {
case <-rangeStarted:
case <-r.Context().Done():
return
}
_, _ = w.Write(resolvePayload)
return
}
rangeRequests.Add(1)
releaseResolve()
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(replacement)))
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(replacement)
}))
defer server.Close()
defer releaseResolve()
f := buildFetcher()
if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}, &base.Options{
@@ -0,0 +1,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 {
d.emit(EventKeyPause, task)
d.triggerWebhooks(WebhookEventDownloadPause, task, 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) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodPost {
+15 -60
View File
@@ -122,7 +122,7 @@
if (name === "set-cookie") {
for (const value of values) result.push([name, value]);
} else {
result.push([name, values.join(", ")]);
result.push([name, values.join(name === "cookie" ? "; " : ", ")]);
}
}
return result;
@@ -158,49 +158,10 @@
return iterator;
}
function forbiddenRequestHeader(name, value) {
if (name.startsWith("proxy-") || name.startsWith("sec-")) return true;
if ([
"accept-charset", "accept-encoding", "access-control-request-headers",
"access-control-request-method", "connection", "content-length", "cookie",
"cookie2", "date", "dnt", "expect", "host", "keep-alive", "origin",
"permissions-policy", "referer", "te", "trailer", "transfer-encoding",
"set-cookie", "upgrade", "via",
].includes(name)) return true;
if (["x-http-method", "x-http-method-override", "x-method-override"].includes(name)) {
return String(value).split(",").some((method) => {
method = method.trim().toUpperCase();
return method === "CONNECT" || method === "TRACE" || method === "TRACK";
});
}
return false;
}
function noCORSSafelistedRequestHeader(name, value) {
if (value.length > 128) return false;
const hasUnsafeByte = /[\x00-\x08\x0A-\x1F\x7F"():<>?@\[\\\]{}]/.test(value);
if (name === "accept") return !hasUnsafeByte;
if (name === "accept-language" || name === "content-language") {
return /^[0-9A-Za-z *,\-.;=]*$/.test(value);
}
if (name === "content-type") {
if (hasUnsafeByte) return false;
const essence = value.split(";", 1)[0].trim().toLowerCase();
return essence === "application/x-www-form-urlencoded" ||
essence === "multipart/form-data" || essence === "text/plain";
}
if (name === "range") return /^bytes=[0-9]+-[0-9]*$/.test(value);
return false;
}
function blockedByGuard(headers, name, value) {
function assertMutableHeaders(headers) {
// Match Node/Undici: HTTP syntax and immutability still apply, but browser
// forbidden-header and no-cors safelist rules do not apply to extensions.
if (headers._guard === "immutable") throw new TypeError("Headers are immutable");
if (headers._guard === "request") return forbiddenRequestHeader(name, value);
if (headers._guard === "request-no-cors") {
return forbiddenRequestHeader(name, value) || !noCORSSafelistedRequestHeader(name, value);
}
if (headers._guard === "response") return name === "set-cookie" || name === "set-cookie2";
return false;
}
class Headers {
@@ -235,27 +196,21 @@
name = normalizeHeaderName(name);
value = normalizeHeaderValue(value);
const values = this._values.get(name);
const combinedValue = values && name !== "set-cookie" ? values.join(", ") + ", " + value : value;
if (blockedByGuard(this, name, combinedValue)) return;
assertMutableHeaders(this);
if (values) values.push(value);
else this._values.set(name, [value]);
}
delete(name) {
name = normalizeHeaderName(name);
if (this._guard === "immutable") throw new TypeError("Headers are immutable");
if (this._guard === "request-no-cors") {
if (forbiddenRequestHeader(name, "") || !["accept", "accept-language", "content-language", "content-type", "range"].includes(name)) return;
this._values.delete(name);
return;
}
if (blockedByGuard(this, name, "")) return;
name = normalizeHeaderName(name);
assertMutableHeaders(this);
this._values.delete(name);
}
get(name) {
const values = this._values.get(normalizeHeaderName(name));
return values ? values.join(", ") : null;
name = normalizeHeaderName(name);
const values = this._values.get(name);
return values ? values.join(name === "cookie" ? "; " : ", ") : null;
}
getSetCookie() {
@@ -268,10 +223,10 @@
}
set(name, value) {
name = normalizeHeaderName(name);
value = normalizeHeaderValue(value);
if (blockedByGuard(this, name, value)) return;
this._values.set(name, [value]);
name = normalizeHeaderName(name);
value = normalizeHeaderValue(value);
assertMutableHeaders(this);
this._values.set(name, [value]);
}
keys() { return createHeadersIterator(this, "key"); }
@@ -484,7 +439,7 @@
}
this._duplex = "half";
const headerInit = init.headers !== undefined ? init.headers : (source ? source.headers : undefined);
this._headers = new Headers(headerInit, this._mode === "no-cors" ? "request-no-cors" : "request");
this._headers = new Headers(headerInit, "request");
let body = init.body !== undefined ? init.body : (source ? source._bodyInit : null);
if (source && !bodyWasOverridden && source.body) body = transferReadableStream(source.body);
const bodyIsStream = typeof ReadableStream !== "undefined" && body instanceof ReadableStream;
@@ -302,6 +302,23 @@ func (r *registry) Open(fingerprint string, proxyHandler func(*http.Request) (*u
return nil, err
}
for _, header := range metadata.Headers {
// Ignore transport controls unsupported by Node/Undici rather than
// applying a browser forbidden-header list or failing the whole request.
switch strings.ToLower(header[0]) {
case "expect", "keep-alive", "transfer-encoding", "upgrade", "content-length":
// Content-Length is generated from the buffered request body below.
continue
case "connection":
if value := strings.ToLower(header[1]); value != "close" && value != "keep-alive" {
continue
}
case "trailer":
// Fetch supplies a declaration, not a Go Request.Trailer payload.
// Keep its lower-case wire name so Go's writer does not omit it as
// the canonical Trailer field it normally generates itself.
httpRequest.Header["trailer"] = append(httpRequest.Header["trailer"], header[1])
continue
}
httpRequest.Header.Add(header[0], header[1])
}
if host := httpRequest.Header.Get("Host"); host != "" {
+26 -2
View File
@@ -9,7 +9,7 @@ The files under `fetch/` are copied from the official
Gopeed executes applicable worker-style Fetch API tests at the extension
JavaScript boundary. The checked-in runner currently executes unchanged WPT
tests for Headers, no-CORS header guards, Request construction and body
tests for Headers syntax and iteration, Request construction and body
consumption, ReadableStream bodies, keepalive, Request errors, Response
construction and body consumption, immutable network response headers, and
the Response static constructors.
@@ -19,10 +19,34 @@ a claim that Gopeed is a browser. Browser-only suites that require a document,
CORS origin enforcement or preflight, CSP, Mixed Content, Service Workers,
browser HTTP cache, navigation, browser authentication UI, or WPT's
multi-origin server infrastructure are intentionally excluded. Gopeed also
keeps one extension-specific behavior: `redirect: "manual"` exposes the HTTP
keeps extension-specific behavior: `redirect: "manual"` exposes the HTTP
redirect response so download extensions can inspect `Location`, instead of
returning a browser `opaqueredirect` response.
Header permissions follow [Node/Undici's server-side Fetch API](https://github.com/nodejs/undici/blob/main/README.md#forbidden-and-safelisted-header-names). Gopeed does not
apply browser forbidden request/response header lists or no-CORS safelists.
Extensions may supply authentication, referrer, origin, host, proxy and fetch
metadata headers, and read `Set-Cookie` response headers. Network response
headers remain immutable; invalid header syntax still raises an error.
Repeated `Cookie` values are combined with `; `, as in Node/Undici.
The runner excludes the browser-only `headers-no-cors.any.js` and
`headers-forbidden-override.any.js` suites, the filtering assertions within
`request-headers.any.js`, and the forbidden-response-cookie assertion within
`header-setcookie.any.js`. Gopeed's `extension_headers_test.go` replaces those
restrictions with integration tests that check headers actually received by
an HTTP server through fetch and XMLHttpRequest, plus header mutations,
cloning and response cookies. The upstream test files remain unchanged.
Gopeed deliberately ignores transport controls that Node/Undici rejects:
`Expect`, `Keep-Alive`, `Transfer-Encoding`, `Upgrade`, and `Connection` values
other than `close` or `keep-alive`. `Content-Length` is generated from the
actual request body, ignoring a supplied length. Explicit `Host` and
`Sec-Fetch-Mode` values are preserved rather than replaced by defaults.
These transport behaviors are also covered by integration tests.
`credentials: "omit"` disables automatic CookieJar cookies but preserves
explicit headers; Gopeed retains its extension CookieJar support.
`wpt_harness.js` is a small Goja adapter maintained by Gopeed; it is not copied
from WPT. Test logic below `fetch/` is unchanged; the checked-in copy may only
normalize a final newline.
+25 -17
View File
@@ -20,9 +20,6 @@ var wptFetchUtils string
//go:embed testdata/wpt/fetch/api/request/request-error.js
var wptRequestError string
//go:embed testdata/wpt/fetch/api/cors/resources/not-cors-safelisted.json
var wptNoCORSSafelistedHeaders []byte
//go:embed testdata/wpt/fetch/api/headers/*.any.js
var wptHeaders embed.FS
@@ -30,31 +27,19 @@ var wptHeaders embed.FS
var wptRequestResponse embed.FS
func TestWPTHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(wptNoCORSSafelistedHeaders)
}))
t.Cleanup(server.Close)
tests := []string{
"header-setcookie.any.js",
"headers-basic.any.js",
"headers-casing.any.js",
"headers-combine.any.js",
"headers-errors.any.js",
"headers-forbidden-override.any.js",
"headers-normalize.any.js",
"headers-no-cors.any.js",
"headers-record.any.js",
"headers-structure.any.js",
}
for _, name := range tests {
t.Run(name, func(t *testing.T) {
setup := ""
if name == "headers-no-cors.any.js" {
setup = fmt.Sprintf("globalThis.location = new URL(%q);", server.URL+"/fetch/api/headers/test.any.js")
}
runWPTFile(t, wptHeaders, path.Join("testdata/wpt/fetch/api/headers", name), setup)
runWPTFile(t, wptHeaders, path.Join("testdata/wpt/fetch/api/headers", name), "")
})
}
}
@@ -110,7 +95,30 @@ func runWPTFile(t *testing.T, files embed.FS, name, setup string) {
}
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(setup + "\n" + wptHarness + "\n" + wptFetchUtils + "\n" + wptRequestError + "\n" + string(source) + "\n__wptFinish();")
adaptation := ""
if name == "testdata/wpt/fetch/api/request/request-headers.any.js" {
// Node-style header permissions are covered by extension integration tests.
// Keep upstream tests unchanged and exclude browser-only restrictions.
adaptation = `
const upstreamTest = globalThis.test;
globalThis.test = function (callback, name) {
if (name.startsWith('Adding invalid request header ') ||
name.startsWith('Adding invalid no-cors request header ') ||
name.startsWith('Check that request constructor is filtering headers ') ||
name.startsWith('Check that no-cors request constructor is filtering headers ')) return;
upstreamTest(callback, name);
};
`
} else if name == "testdata/wpt/fetch/api/headers/header-setcookie.any.js" {
adaptation = `
const upstreamTest = globalThis.test;
globalThis.test = function (callback, name) {
if (name === 'Set-Cookie is a forbidden response header') return;
upstreamTest(callback, name);
};
`
}
value, err := runtime.RunString(setup + "\n" + wptHarness + "\n" + wptFetchUtils + "\n" + wptRequestError + "\n" + adaptation + "\n" + string(source) + "\n__wptFinish();")
if err != nil {
t.Fatal(err)
}
+3
View File
@@ -23,6 +23,9 @@ type ResolveResult struct {
Res *base.Resource `json:"res"`
}
// Task represents a download unit.
// Options flow path:
// REST Request (model.CreateTask/ResolveTask) -> pkg/download Downloader -> Task.Meta.Opts (base.Options) -> Fetcher.Resolve/Start (e.g. internal/protocol/http/fetcher.go)
type Task struct {
ID string `json:"id"`
Protocol string `json:"protocol"`
+1
View File
@@ -21,6 +21,7 @@ type WebhookEvent string
const (
WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE"
WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
WebhookEventDownloadPause WebhookEvent = "DOWNLOAD_PAUSE"
)
// WebhookData is the data sent to webhook URLs
+43
View File
@@ -814,6 +814,49 @@ func TestWebhook_TestWebhookUrl_VerifyTestPayload(t *testing.T) {
})
}
func TestWebhook_TriggerOnPause(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{server.URL},
}
downloader.PutConfig(cfg)
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
downloader.triggerWebhooks(WebhookEventDownloadPause, task, nil)
select {
case data := <-receivedData:
if data.Event != WebhookEventDownloadPause {
t.Errorf("Expected event 'DOWNLOAD_PAUSE', got '%s'", data.Event)
}
if data.Payload == nil || data.Payload.Task == nil {
t.Error("Expected payload.task to be present")
} else if data.Payload.Task.ID != task.ID {
t.Errorf("Expected task ID '%s', got '%s'", task.ID, data.Payload.Task.ID)
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
defaultDownloader.Setup()
defaultDownloader.cfg.StorageDir = ".test_storage"
+67 -7
View File
@@ -12,6 +12,7 @@ import (
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
phttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@@ -65,12 +66,13 @@ type btRequestExtra struct {
}
type httpTaskExtra struct {
Connections int `json:"connections,omitempty" jsonschema:"Number of HTTP download connections"`
AutoTorrent *bool `json:"autoTorrent,omitempty" jsonschema:"Automatically create a BitTorrent task after downloading a torrent file"`
DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload,omitempty" jsonschema:"Delete the torrent file after automatically creating its BitTorrent task"`
AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"`
ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"`
DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"`
Connections int `json:"connections,omitempty" jsonschema:"Number of HTTP download connections"`
AutoTorrent *bool `json:"autoTorrent,omitempty" jsonschema:"Automatically create a BitTorrent task after downloading a torrent file"`
DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload,omitempty" jsonschema:"Delete the torrent file after automatically creating its BitTorrent task"`
AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"`
ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"`
DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"`
Checksum *checksumOption `json:"checksum,omitempty" jsonschema:"Optional checksum verification settings"`
}
// downloadRequest is the MCP-facing form of base.Request. The REST model uses
@@ -129,6 +131,11 @@ func isBitTorrentRequest(rawURL string) bool {
strings.HasPrefix(upperURL, "DATA:APPLICATION/X-BITTORRENT;BASE64,")
}
type checksumOption struct {
Algorithm string `json:"algorithm" jsonschema:"Checksum algorithm: md5, sha1, or sha256"`
Expected string `json:"expected" jsonschema:"Expected hex-encoded checksum hash"`
}
// downloadOptions is the MCP-facing form of base.Options. Only HTTP currently
// defines protocol-specific task options, so Extra has one concrete schema.
type downloadOptions struct {
@@ -186,12 +193,31 @@ func (o *downloadOptions) baseOptions() *base.Options {
if o == nil {
return nil
}
var extra any
if o.Extra != nil {
var checksum *phttp.ChecksumOption
if o.Extra.Checksum != nil {
checksum = &phttp.ChecksumOption{
Algorithm: o.Extra.Checksum.Algorithm,
Expected: o.Extra.Checksum.Expected,
}
}
extra = &phttp.OptsExtra{
Connections: o.Extra.Connections,
AutoTorrent: o.Extra.AutoTorrent,
DeleteTorrentAfterDownload: o.Extra.DeleteTorrentAfterDownload,
AutoExtract: o.Extra.AutoExtract,
ArchivePassword: o.Extra.ArchivePassword,
DeleteAfterExtract: o.Extra.DeleteAfterExtract,
Checksum: checksum,
}
}
return &base.Options{
Name: o.Name,
Path: o.Path,
AsDefaultPath: o.AsDefaultPath,
SelectFiles: o.SelectFiles,
Extra: o.Extra,
Extra: extra,
}
}
@@ -270,6 +296,13 @@ type getTaskStatsOutput struct {
Stats any `json:"stats"`
}
type getDownloadSpeedInput struct{}
type getDownloadSpeedOutput struct {
TotalSpeed int64 `json:"totalSpeed"`
ActiveTaskCount int `json:"activeTaskCount"`
}
type taskActionOutput struct {
ID string `json:"id"`
Success bool `json:"success"`
@@ -416,6 +449,33 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) {
return nil, &getTaskStatsOutput{Stats: stats}, nil
})
mcp.AddTool(server, &mcp.Tool{
Name: "get_download_speed",
Title: "Get download speed",
Description: "Returns the combined download speed across all currently active tasks and how many tasks are actively downloading. Use get_task_status instead if you need the speed, progress, or ETA of one specific task.",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closedWorld},
}, func(_ context.Context, _ *mcp.CallToolRequest, _ *getDownloadSpeedInput) (*mcp.CallToolResult, *getDownloadSpeedOutput, error) {
allTasks := downloader.GetTasks()
var totalSpeed int64
activeCount := 0
for _, task := range allTasks {
status, err := downloader.RuntimeStatus(task.ID)
if err != nil {
continue
}
if status.Status == base.DownloadStatusRunning {
activeCount++
totalSpeed += status.Speed
}
}
return nil, &getDownloadSpeedOutput{
TotalSpeed: totalSpeed,
ActiveTaskCount: activeCount,
}, nil
})
registerTaskAction(server, downloader, "pause_task", "Pause task", "Pause one Gopeed task.", &nondestructive, &closedWorld, downloader.Pause)
registerTaskAction(server, downloader, "continue_task", "Continue task", "Continue a paused or failed Gopeed task.", &nondestructive, &openWorld, downloader.Continue)
+1
View File
@@ -64,6 +64,7 @@ func TestStreamableHTTPTools(t *testing.T) {
"continue_task",
"create_task",
"delete_task",
"get_download_speed",
"get_task",
"get_task_stats",
"get_task_status",
+103
View File
@@ -0,0 +1,103 @@
package mcpserver
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestGetDownloadSpeedTool(t *testing.T) {
// Serve a test payload that takes a little bit of time or streams bytes
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "1048576")
w.WriteHeader(http.StatusOK)
chunk := make([]byte, 8192)
for i := 0; i < 128; i++ {
_, _ = w.Write(chunk)
time.Sleep(10 * time.Millisecond)
}
}))
defer testServer.Close()
downloader := download.NewDownloader(&download.DownloaderConfig{
Storage: download.NewMemStorage(),
StorageDir: t.TempDir(),
})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Close()
httpServer := httptest.NewServer(NewHandler(downloader))
defer httpServer.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "gopeed-test", Version: "1.0.0"}, nil)
session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, nil)
if err != nil {
t.Fatal(err)
}
defer session.Close()
// 1. Zero tasks test (returns 0 totalSpeed and 0 activeTaskCount)
result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "get_download_speed",
Arguments: map[string]any{},
})
if err != nil {
t.Fatal(err)
}
if result.IsError {
t.Fatalf("get_download_speed returned error: %+v", result.Content)
}
structured, ok := result.StructuredContent.(map[string]any)
if !ok {
t.Fatalf("structured content type = %T", result.StructuredContent)
}
if activeCount, ok := structured["activeTaskCount"].(float64); !ok || activeCount != 0 {
t.Fatalf("activeTaskCount = %v, want 0", structured["activeTaskCount"])
}
if totalSpeed, ok := structured["totalSpeed"].(float64); !ok || totalSpeed != 0 {
t.Fatalf("totalSpeed = %v, want 0", structured["totalSpeed"])
}
// 2. Start a real download task
taskID, err := downloader.CreateDirect(&base.Request{
URL: testServer.URL,
}, &base.Options{
Path: t.TempDir(),
})
if err != nil {
t.Fatal(err)
}
defer func() {
_ = downloader.Delete(&download.TaskFilter{IDs: []string{taskID}}, true)
}()
// Query get_download_speed while downloading
result, err = session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "get_download_speed",
Arguments: map[string]any{},
})
if err != nil {
t.Fatal(err)
}
if result.IsError {
t.Fatalf("get_download_speed returned error: %+v", result.Content)
}
structured, ok = result.StructuredContent.(map[string]any)
if !ok {
t.Fatalf("structured content type = %T", result.StructuredContent)
}
if _, ok := structured["activeTaskCount"].(float64); !ok {
t.Fatalf("activeTaskCount missing or wrong type: %v", structured["activeTaskCount"])
}
if _, ok := structured["totalSpeed"].(float64); !ok {
t.Fatalf("totalSpeed missing or wrong type: %v", structured["totalSpeed"])
}
}
+74
View File
@@ -1,11 +1,83 @@
package http
import (
"encoding/hex"
"fmt"
"strings"
)
type ReqExtra struct {
Method string `json:"method"`
Header map[string]string `json:"header"`
Body string `json:"body"`
}
// ChecksumOption specifies an expected checksum to verify against
// the completed download. Only supported for HTTP downloads.
type ChecksumOption struct {
Algorithm string `json:"algorithm"` // "md5" | "sha1" | "sha256"
Expected string `json:"expected"` // hex-encoded expected hash
}
// IsEmpty returns true when chk is nil, or when both Algorithm and Expected
// are blank after trimming whitespace.
func (chk *ChecksumOption) IsEmpty() bool {
if chk == nil {
return true
}
return strings.TrimSpace(chk.Algorithm) == "" && strings.TrimSpace(chk.Expected) == ""
}
// NormalizedAlgorithm returns the canonical lower-cased algorithm ("md5", "sha1", "sha256")
// and true if recognized, or ("", false) if unsupported.
func (chk *ChecksumOption) NormalizedAlgorithm() (string, bool) {
if chk == nil {
return "", false
}
switch strings.ToLower(strings.TrimSpace(chk.Algorithm)) {
case "md5":
return "md5", true
case "sha1", "sha-1":
return "sha1", true
case "sha256", "sha-256":
return "sha256", true
default:
return "", false
}
}
// Validate checks whether the checksum option is valid.
// Empty options return nil (no verification).
func (chk *ChecksumOption) Validate() error {
if chk.IsEmpty() {
return nil
}
algo, ok := chk.NormalizedAlgorithm()
if !ok {
return fmt.Errorf("unsupported checksum algorithm: %s", chk.Algorithm)
}
expected := strings.TrimSpace(chk.Expected)
if expected == "" {
return fmt.Errorf("checksum expected hash is required when algorithm is set")
}
var expectedLen int
switch algo {
case "md5":
expectedLen = 32
case "sha1":
expectedLen = 40
case "sha256":
expectedLen = 64
}
if len(expected) != expectedLen {
return fmt.Errorf("invalid checksum hash length for %s: expected %d characters, got %d", algo, expectedLen, len(expected))
}
if _, err := hex.DecodeString(expected); err != nil {
return fmt.Errorf("invalid checksum hash for %s: expected valid hexadecimal string", algo)
}
return nil
}
type OptsExtra struct {
Connections int `json:"connections"`
// AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file
@@ -21,6 +93,8 @@ type OptsExtra struct {
ArchivePassword string `json:"archivePassword"`
// DeleteAfterExtract when true, deletes the archive file after successful extraction
DeleteAfterExtract bool `json:"deleteAfterExtract"`
// Checksum verifies the completed file against an expected hash after download
Checksum *ChecksumOption `json:"checksum,omitempty"`
}
// Stats for download
+162
View File
@@ -0,0 +1,162 @@
package http
import (
"strings"
"testing"
)
func TestOptsExtra_Checksum(t *testing.T) {
extra := &OptsExtra{
Checksum: &ChecksumOption{
Algorithm: "sha256",
Expected: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
}
if extra.Checksum.Algorithm != "sha256" || extra.Checksum.Expected != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
t.Fatalf("unexpected checksum: %+v", extra.Checksum)
}
}
func TestChecksumOption_Validate(t *testing.T) {
validMd5 := "0123456789abcdef0123456789abcdef"
validSha1 := "0123456789abcdef0123456789abcdef01234567"
validSha256 := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
tests := []struct {
name string
option *ChecksumOption
wantErr bool
errSubstr string
}{
{
name: "nil option",
option: nil,
wantErr: false,
},
{
name: "empty option (empty strings)",
option: &ChecksumOption{Algorithm: "", Expected: ""},
wantErr: false,
},
{
name: "empty option (whitespace strings)",
option: &ChecksumOption{Algorithm: " ", Expected: " "},
wantErr: false,
},
{
name: "valid md5 lowercase",
option: &ChecksumOption{Algorithm: "md5", Expected: validMd5},
wantErr: false,
},
{
name: "valid md5 uppercase",
option: &ChecksumOption{Algorithm: "MD5", Expected: strings.ToUpper(validMd5)},
wantErr: false,
},
{
name: "valid sha1 lowercase",
option: &ChecksumOption{Algorithm: "sha1", Expected: validSha1},
wantErr: false,
},
{
name: "valid sha-1 alias",
option: &ChecksumOption{Algorithm: "SHA-1", Expected: strings.ToUpper(validSha1)},
wantErr: false,
},
{
name: "valid sha256 lowercase",
option: &ChecksumOption{Algorithm: "sha256", Expected: validSha256},
wantErr: false,
},
{
name: "valid sha-256 alias",
option: &ChecksumOption{Algorithm: "sha-256", Expected: validSha256},
wantErr: false,
},
{
name: "valid sha256 with whitespace around hash and algorithm",
option: &ChecksumOption{Algorithm: " sha256 ", Expected: " " + validSha256 + " "},
wantErr: false,
},
{
name: "unknown algorithm",
option: &ChecksumOption{Algorithm: "crc32", Expected: "12345678"},
wantErr: true,
errSubstr: "unsupported checksum algorithm",
},
{
name: "algorithm set but hash blank",
option: &ChecksumOption{Algorithm: "sha256", Expected: " "},
wantErr: true,
errSubstr: "checksum expected hash is required",
},
{
name: "sha256 with abc (short length)",
option: &ChecksumOption{Algorithm: "sha256", Expected: "abc"},
wantErr: true,
errSubstr: "expected 64 characters, got 3",
},
{
name: "sha256 with 64 x g (non-hex)",
option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("g", 64)},
wantErr: true,
errSubstr: "expected valid hexadecimal string",
},
{
name: "sha256 with 63 x a (off-by-one short)",
option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("a", 63)},
wantErr: true,
errSubstr: "expected 64 characters, got 63",
},
{
name: "sha256 with 65 x a (off-by-one long)",
option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("a", 65)},
wantErr: true,
errSubstr: "expected 64 characters, got 65",
},
{
name: "md5 with 31 chars",
option: &ChecksumOption{Algorithm: "md5", Expected: strings.Repeat("a", 31)},
wantErr: true,
errSubstr: "expected 32 characters, got 31",
},
{
name: "md5 with 33 chars",
option: &ChecksumOption{Algorithm: "md5", Expected: strings.Repeat("a", 33)},
wantErr: true,
errSubstr: "expected 32 characters, got 33",
},
{
name: "md5 with sha256-length hash (64 chars)",
option: &ChecksumOption{Algorithm: "md5", Expected: validSha256},
wantErr: true,
errSubstr: "expected 32 characters, got 64",
},
{
name: "sha1 with 39 chars",
option: &ChecksumOption{Algorithm: "sha1", Expected: strings.Repeat("a", 39)},
wantErr: true,
errSubstr: "expected 40 characters, got 39",
},
{
name: "sha1 with 41 chars",
option: &ChecksumOption{Algorithm: "sha1", Expected: strings.Repeat("a", 41)},
wantErr: true,
errSubstr: "expected 40 characters, got 41",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.option.Validate()
if (err != nil) != tt.wantErr {
t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr && tt.errSubstr != "" {
if err == nil || !strings.Contains(err.Error(), tt.errSubstr) {
t.Fatalf("Validate() error = %v, want substring %q", err, tt.errSubstr)
}
}
})
}
}
+17
View File
@@ -17,6 +17,21 @@ class Options {
Map<String, dynamic> toJson() => _$OptionsToJson(this);
}
@JsonSerializable()
class ChecksumOption {
String algorithm;
String expected;
ChecksumOption({
this.algorithm = '',
this.expected = '',
});
factory ChecksumOption.fromJson(Map<String, dynamic> json) => _$ChecksumOptionFromJson(json);
Map<String, dynamic> toJson() => _$ChecksumOptionToJson(this);
}
@JsonSerializable()
class OptsExtraHttp {
int connections;
@@ -25,6 +40,7 @@ class OptsExtraHttp {
bool? autoExtract;
String archivePassword;
bool deleteAfterExtract;
ChecksumOption? checksum;
OptsExtraHttp({
this.connections = 0,
@@ -33,6 +49,7 @@ class OptsExtraHttp {
this.autoExtract,
this.archivePassword = '',
this.deleteAfterExtract = false,
this.checksum,
});
factory OptsExtraHttp.fromJson(Map<String, dynamic> json) => _$OptsExtraHttpFromJson(json);
+14
View File
@@ -22,6 +22,16 @@ Map<String, dynamic> _$OptionsToJson(Options instance) => <String, dynamic>{
'extra': ?instance.extra,
};
ChecksumOption _$ChecksumOptionFromJson(Map<String, dynamic> json) => ChecksumOption(
algorithm: json['algorithm'] as String? ?? '',
expected: json['expected'] as String? ?? '',
);
Map<String, dynamic> _$ChecksumOptionToJson(ChecksumOption instance) => <String, dynamic>{
'algorithm': instance.algorithm,
'expected': instance.expected,
};
OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHttp(
connections: (json['connections'] as num?)?.toInt() ?? 0,
autoTorrent: json['autoTorrent'] as bool?,
@@ -29,6 +39,9 @@ OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHtt
autoExtract: json['autoExtract'] as bool?,
archivePassword: json['archivePassword'] as String? ?? '',
deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false,
checksum: json['checksum'] == null
? null
: ChecksumOption.fromJson(json['checksum'] as Map<String, dynamic>),
);
Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, dynamic>{
@@ -38,4 +51,5 @@ Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, d
'autoExtract': ?instance.autoExtract,
'archivePassword': instance.archivePassword,
'deleteAfterExtract': instance.deleteAfterExtract,
'checksum': ?instance.checksum?.toJson(),
};
@@ -185,12 +185,11 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
}
bool canUpdateItem(ExtensionListItem item) {
final current = _current;
if (item.installed == null) return false;
if (current.listFilter == ExtensionListFilter.market && item.store != null) {
return _compareVersion(item.store!.version, item.installed!.version) > 0;
}
return current.updateFlags.containsKey(item.installed!.identity);
final installed = item.installed;
if (installed == null) return false;
// The backend checks GitHub live for every installed extension; the store
// index may lag behind and must not decide update visibility.
return _current.updateFlags.containsKey(installed.identity);
}
Future<void> installFromStore(StoreExtension extension) async {
@@ -240,6 +239,9 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
await _runBusy(extension.identity, () async {
await ref.read(gopeedServiceProvider).updateExtension(extension.identity);
await loadInstalled(refreshUpdates: false);
// Drop the stale flag immediately; the background re-check confirms it.
final flags = Map<String, String>.of(_current.updateFlags)..remove(extension.identity);
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
unawaited(checkUpdate());
_bumpStoreInstallCount(extension.identity);
_reportInstallSafe(extension.identity);
@@ -354,24 +356,4 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
}());
}
static int _compareVersion(String a, String b) {
final aNums = _toVersionNumbers(a);
final bNums = _toVersionNumbers(b);
final maxLen = aNums.length > bNums.length ? aNums.length : bNums.length;
for (var i = 0; i < maxLen; i++) {
final left = i < aNums.length ? aNums[i] : 0;
final right = i < bNums.length ? bNums[i] : 0;
if (left > right) return 1;
if (left < right) return -1;
}
return 0;
}
static List<int> _toVersionNumbers(String version) {
return version
.split(RegExp(r'[^0-9]+'))
.where((part) => part.isNotEmpty)
.map((part) => int.tryParse(part) ?? 0)
.toList();
}
}
@@ -8,6 +8,8 @@ import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/detail/app_detail_surface.dart';
import '../../application/extensions_controller.dart';
import '../widgets/extension_detail_view.dart';
import '../widgets/extension_update_dialog.dart';
import '../widgets/extension_update_status.dart';
class ExtensionDetailsPage extends ConsumerWidget {
const ExtensionDetailsPage({super.key, required this.extensionId, this.initialItem});
@@ -24,6 +26,15 @@ class ExtensionDetailsPage extends ConsumerWidget {
return AppDetailPage(
title: item?.title ?? context.l10n.extensions,
onBack: () => context.canPop() ? context.pop() : context.go('/extensions'),
titleTrailing: _canUpdate(stateAsync.value, item)
? ExtensionUpdateStatus(
key: const ValueKey('extension-details-update-mobile'),
label: context.l10n.extensionCanUpdate,
onTap: item?.installed == null
? null
: () => showExtensionUpdateDialog(context, item!.installed!),
)
: null,
child: item == null
? Center(
child: stateAsync.isLoading
@@ -33,4 +44,9 @@ class ExtensionDetailsPage extends ConsumerWidget {
: ExtensionDetailView(key: ValueKey(item.id), item: item, mobile: true),
);
}
bool _canUpdate(ExtensionsState? state, ExtensionListItem? item) {
final installed = item?.installed;
return item != null && installed != null && (state?.updateFlags.containsKey(installed.identity) ?? false);
}
}
@@ -28,6 +28,7 @@ import '../widgets/extension_detail_view.dart';
import '../widgets/extension_icon.dart';
import '../widgets/extension_setting_field.dart';
import '../widgets/extension_update_dialog.dart';
import '../widgets/extension_update_status.dart';
const _extensionCardMinWidth = 290.0;
const _extensionGridSpacing = 10.0;
@@ -485,25 +486,7 @@ class _Content extends StatelessWidget {
}
bool _canUpdate(ExtensionsState state, ExtensionListItem item) {
if (item.installed == null) return false;
if (state.listFilter == ExtensionListFilter.market && item.store != null) {
return _compareVersion(item.store!.version, item.installed!.version) > 0;
}
return state.updateFlags.containsKey(item.installed!.identity);
}
int _compareVersion(String a, String b) {
final aNums = a.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0);
final bNums = b.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0);
final left = aNums.toList();
final right = bNums.toList();
final maxLen = left.length > right.length ? left.length : right.length;
for (var i = 0; i < maxLen; i++) {
final l = i < left.length ? left[i] : 0;
final r = i < right.length ? right[i] : 0;
if (l != r) return l.compareTo(r);
}
return 0;
return item.installed != null && state.updateFlags.containsKey(item.installed!.identity);
}
}
@@ -994,11 +977,28 @@ class _ExtensionCard extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700),
Row(
children: [
Flexible(
child: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700),
),
),
if (canUpdate && installed != null) ...[
const SizedBox(width: 4),
Flexible(
child: ExtensionUpdateStatus(
key: ValueKey('extension-card-update-status-${item.id}'),
label: context.l10n.extensionCanUpdate,
onTap: busy ? null : () => showExtensionUpdateDialog(context, installed),
compact: true,
),
),
],
],
),
const SizedBox(height: 2),
Text(
@@ -1067,13 +1067,6 @@ class _ExtensionCard extends ConsumerWidget {
key: ValueKey('extension-card-actions-${item.id}'),
mainAxisSize: MainAxisSize.min,
children: [
if (canUpdate && installed != null)
shad.GhostButton(
density: shad.ButtonDensity.icon,
key: ValueKey('update-extension-${installed.identity}'),
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
child: const Icon(Icons.refresh),
),
if (installed == null && item.store != null)
shad.GhostButton(
key: ValueKey('install-store-extension-${item.store!.id}'),
@@ -1127,21 +1120,17 @@ class _ExtensionCard extends ConsumerWidget {
],
),
);
if (item.store == null) return card;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
key: ValueKey('open-extension-details-${item.id}'),
behavior: HitTestBehavior.opaque,
onTap: () {
if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) {
context.push('/extensions/${Uri.encodeComponent(item.id)}', extra: item);
return;
}
onOpenDetails(item);
},
child: card,
),
return GestureDetector(
key: ValueKey('open-extension-details-${item.id}'),
behavior: HitTestBehavior.opaque,
onTap: () {
if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) {
context.push('/extensions/${Uri.encodeComponent(item.id)}', extra: item);
return;
}
onOpenDetails(item);
},
child: card,
);
}
}
@@ -21,20 +21,33 @@ import '../../../../util/util.dart';
import '../../application/extensions_controller.dart';
import 'extension_icon.dart';
import 'extension_update_dialog.dart';
import 'extension_update_status.dart';
class ExtensionDetailDrawer extends StatelessWidget {
class ExtensionDetailDrawer extends ConsumerWidget {
const ExtensionDetailDrawer({super.key, required this.item, required this.onClose});
final ExtensionListItem? item;
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(extensionsControllerProvider).value;
final installed = item?.installed;
final canUpdate =
item != null && installed != null && (state?.updateFlags.containsKey(installed.identity) ?? false);
final updateChip = canUpdate
? ExtensionUpdateStatus(
key: const ValueKey('extension-details-update'),
label: context.l10n.extensionCanUpdate,
onTap: () => showExtensionUpdateDialog(context, installed),
)
: null;
return AppDetailDrawer(
open: item != null,
title: item?.title ?? '',
onClose: onClose,
drawerKey: const ValueKey('extension-details-drawer'),
titleTrailing: updateChip,
child: item == null ? const SizedBox.shrink() : ExtensionDetailView(item: item!),
);
}
@@ -104,8 +117,6 @@ class ExtensionDetailView extends ConsumerWidget {
style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600),
),
if (store != null) _ExtensionStats(store: store),
if (installed != null && canUpdate)
_ExtensionStatus(label: context.l10n.extensionCanUpdate, emphasized: true),
],
),
],
@@ -130,15 +141,6 @@ class ExtensionDetailView extends ConsumerWidget {
_runAction(context, () => ref.read(extensionsControllerProvider.notifier).installFromStore(store)),
child: Text(context.l10n.extensionInstall),
),
if (installed != null && canUpdate)
AppLoadingButton(
key: const ValueKey('extension-details-update'),
loading: false,
variant: AppLoadingButtonVariant.primary,
icon: const Icon(Icons.refresh, size: 16),
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
child: Text(context.l10n.newVersionUpdate),
),
if (installed != null && !canUpdate)
AppLoadingButton(
key: const ValueKey('extension-details-installed'),
@@ -162,7 +164,13 @@ class ExtensionDetailView extends ConsumerWidget {
),
],
),
if (store != null) ...[SizedBox(height: mobile ? 24 : 28), _ExtensionReadme(item: current, mobile: mobile)],
SizedBox(height: mobile ? 24 : 28),
_ExtensionReadme(
// Snapshot the version so upgrades reload the installed README.
key: ValueKey((current.id, installed?.version, installed?.devMode, installed?.devPath, store?.readme)),
item: current,
mobile: mobile,
),
],
);
}
@@ -180,7 +188,7 @@ class ExtensionDetailView extends ConsumerWidget {
}
class _ExtensionReadme extends StatefulWidget {
const _ExtensionReadme({required this.item, required this.mobile});
const _ExtensionReadme({super.key, required this.item, required this.mobile});
final ExtensionListItem item;
final bool mobile;
@@ -190,7 +198,7 @@ class _ExtensionReadme extends StatefulWidget {
}
class _ExtensionReadmeState extends State<_ExtensionReadme> {
late Future<_ReadmeInfo> _readme;
late final _ReadmeInfo _readme;
@override
void initState() {
@@ -198,109 +206,48 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> {
_readme = _loadReadme(widget.item);
}
@override
void didUpdateWidget(covariant _ExtensionReadme oldWidget) {
super.didUpdateWidget(oldWidget);
final previous = oldWidget.item.installed;
final current = widget.item.installed;
if (previous?.identity != current?.identity ||
previous?.devMode != current?.devMode ||
previous?.devPath != current?.devPath ||
oldWidget.item.store?.readme != widget.item.store?.readme) {
_readme = _loadReadme(widget.item);
}
}
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return FutureBuilder<_ReadmeInfo>(
future: _readme,
builder: (context, snapshot) {
final info = snapshot.data;
if (info == null) {
return const SizedBox.shrink();
}
if (info.content.trim().isEmpty) {
return const SizedBox.shrink();
}
return Padding(
key: const ValueKey('extension-details-readme'),
padding: EdgeInsets.only(top: widget.mobile ? 32 : 36),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'README',
style: TextStyle(
color: palette.textMuted,
fontSize: 10.5,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
),
),
const SizedBox(height: 16),
MarkdownBody(
data: info.content,
selectable: true,
styleSheet: _markdownStyle(palette, mobile: widget.mobile),
onTapLink: (_, href, _) {
final resolved = _resolveReadmeUrl(widget.item, href, info: info, forImage: false);
if (resolved != null) unawaited(_openUrl(resolved));
},
imageBuilder: (uri, title, alt) {
final resolved = _resolveReadmeUrl(widget.item, uri.toString(), info: info, forImage: true);
if (resolved == null) return const SizedBox.shrink();
final image = resolved.startsWith('file:')
? Image.file(
File(Uri.parse(resolved).toFilePath()),
fit: BoxFit.contain,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
)
: Image.network(
resolved,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: ClipRRect(borderRadius: BorderRadius.circular(6), child: image),
);
},
),
],
final info = _readme;
if (info.content.trim().isEmpty) return const SizedBox.shrink();
return Padding(
key: const ValueKey('extension-details-readme'),
padding: EdgeInsets.only(top: widget.mobile ? 32 : 36),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'README',
style: TextStyle(color: palette.textMuted, fontSize: 10.5, fontWeight: FontWeight.w800, letterSpacing: 1.2),
),
);
},
);
}
}
class _ExtensionStatus extends StatelessWidget {
const _ExtensionStatus({required this.label, required this.emphasized});
final String label;
final bool emphasized;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final dotColor = emphasized ? palette.brand : palette.success;
final textColor = emphasized ? palette.textPrimary : palette.success;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 5,
height: 5,
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(color: textColor, fontSize: 11.5, fontWeight: FontWeight.w600),
),
],
const SizedBox(height: 16),
MarkdownBody(
data: info.content,
selectable: true,
styleSheet: _markdownStyle(palette, mobile: widget.mobile),
onTapLink: (_, href, _) {
final resolved = _resolveReadmeUrl(widget.item, href, info: info, forImage: false);
if (resolved != null) unawaited(_openUrl(resolved));
},
imageBuilder: (uri, title, alt) {
final resolved = _resolveReadmeUrl(widget.item, uri.toString(), info: info, forImage: true);
if (resolved == null) return const SizedBox.shrink();
final image = resolved.startsWith('file:')
? Image.file(
File(Uri.parse(resolved).toFilePath()),
fit: BoxFit.contain,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
)
: Image.network(resolved, fit: BoxFit.contain, errorBuilder: (_, _, _) => const SizedBox.shrink());
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: ClipRRect(borderRadius: BorderRadius.circular(6), child: image),
);
},
),
],
),
);
}
}
@@ -407,7 +354,7 @@ MarkdownStyleSheet _markdownStyle(AppPalette palette, {required bool mobile}) {
);
}
Future<_ReadmeInfo> _loadReadme(ExtensionListItem item) async {
_ReadmeInfo _loadReadme(ExtensionListItem item) {
final installed = item.installed;
final remote = item.store?.readme ?? '';
if (installed == null || Util.isWeb()) return _ReadmeInfo(content: remote);
@@ -58,7 +58,7 @@ class _ExtensionUpdateDialogState extends ConsumerState<_ExtensionUpdateDialog>
canPop: !_updating,
child: AlertDialog(
key: const ValueKey('extension-update-dialog'),
title: Text(context.l10n.extensionCanUpdate),
title: Text(context.l10n.newVersionAvailable),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -0,0 +1,59 @@
import 'package:flutter/widgets.dart';
import '../../../../shared/theme/app_palette.dart';
/// Green status dot with an optional label, marking an available update.
/// Pass [onTap] to make it open the update prompt.
class ExtensionUpdateStatus extends StatelessWidget {
const ExtensionUpdateStatus({super.key, this.label, this.onTap, this.compact = false});
final String? label;
final VoidCallback? onTap;
final bool compact;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final content = Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: compact ? 2 : 3),
decoration: BoxDecoration(
color: palette.brandSoft,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: palette.brand.withValues(alpha: 0.35)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: palette.brand, shape: BoxShape.circle),
),
if (label != null) ...[
const SizedBox(width: 5),
Flexible(
child: Text(
label!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 11.5, fontWeight: FontWeight.w600),
),
),
],
],
),
);
if (onTap == null) return content;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 2, vertical: compact ? 2 : 4),
child: content,
),
),
);
}
}
@@ -66,6 +66,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
final _httpHeadersController = AppHttpHeadersController(defaultNames: const ['User-Agent', 'Cookie', 'Referer']);
final _trackersController = TextEditingController();
final _archivePasswordController = TextEditingController();
final _checksumHashController = TextEditingController();
final _formScrollController = ScrollController();
String _httpMethod = 'GET';
@@ -74,6 +75,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
Map<String, String>? _initialLabels;
RequestProxyMode _proxyMode = RequestProxyMode.follow;
String _proxyScheme = 'http';
String _checksumAlgorithm = 'md5';
bool _skipVerifyCert = false;
bool? _autoTorrent;
bool? _deleteTorrentAfterDownload;
@@ -134,6 +136,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_httpHeadersController.dispose();
_trackersController.dispose();
_archivePasswordController.dispose();
_checksumHashController.dispose();
_formScrollController.dispose();
super.dispose();
}
@@ -184,6 +187,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) {
_fileDataUri = '';
}
if (_inputUrls().length > 1) {
_checksumHashController.clear();
_checksumAlgorithm = 'md5';
}
}
_recognizeMagnetUri(urlText.trim());
}
@@ -193,6 +200,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
final req = task.req;
final opts = task.opts;
setState(() {
_checksumAlgorithm = 'md5';
_checksumHashController.clear();
if (req?.url.isNotEmpty == true) {
_fileDataUri = '';
_renameController.clear();
@@ -243,6 +252,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_autoExtract = extra.autoExtract;
_archivePasswordController.text = extra.archivePassword;
_deleteAfterExtract = extra.deleteAfterExtract;
if (extra.checksum != null) {
if (extra.checksum!.algorithm.isNotEmpty) {
_checksumAlgorithm = extra.checksum!.algorithm;
}
_checksumHashController.text = extra.checksum!.expected;
}
}
}
});
@@ -668,6 +683,34 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
),
),
],
if (_inputUrls().length <= 1) ...[
const SizedBox(height: 14),
AppFormPair(
direction: stacked ? Axis.vertical : Axis.horizontal,
firstLabel: context.l10n.checksumAlgorithm,
secondLabel: context.l10n.checksumHash,
firstFlex: 382,
secondFlex: 618,
first: AppChoiceSegmentedControl<String>(
key: const ValueKey('create-task-checksum-algorithm'),
showIcons: false,
value: _checksumAlgorithm,
buttonKeyPrefix: 'create-task-checksum-algorithm',
alignment: WrapAlignment.start,
options: const [
AppChoiceOption(value: 'md5', label: 'MD5', icon: Icons.tag),
AppChoiceOption(value: 'sha1', label: 'SHA-1', icon: Icons.tag),
AppChoiceOption(value: 'sha256', label: 'SHA-256', icon: Icons.tag),
],
onChanged: (value) => setState(() => _checksumAlgorithm = value),
),
second: _WindowTextField(
key: const ValueKey('create-task-checksum-expected'),
controller: _checksumHashController,
hintText: context.l10n.checksumExpectedHint,
),
),
],
] else ...[
AppFormRow(
direction: stacked ? Axis.vertical : Axis.horizontal,
@@ -950,6 +993,25 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
}
bool _validateAdvancedOptions() {
if (_protocolTab == 0 && _inputUrls().length <= 1) {
final checksumExpected = _checksumHashController.text.trim();
if (checksumExpected.isNotEmpty) {
final expectedLen = switch (_checksumAlgorithm.toLowerCase()) {
'md5' => 32,
'sha1' || 'sha-1' => 40,
'sha256' || 'sha-256' => 64,
_ => 0,
};
if (expectedLen > 0 && checksumExpected.length != expectedLen) {
_showToast(context.l10n.checksumInvalidLength(_checksumAlgorithm.toUpperCase(), expectedLen.toString()));
return false;
}
if (!RegExp(r'^[0-9a-fA-F]+$').hasMatch(checksumExpected)) {
_showToast(context.l10n.checksumInvalidHex);
return false;
}
}
}
if (_proxyMode != RequestProxyMode.custom) {
return true;
}
@@ -1022,8 +1084,15 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
return text.length == 40 && RegExp(r'^[0-9a-fA-F]+$').hasMatch(text);
}
api_options.Options _buildOptions({String? name, List<int> selectFiles = const []}) {
api_options.Options _buildOptions({String? name, List<int> selectFiles = const [], bool omitChecksum = false}) {
final connections = int.tryParse(_connectionsController.text.trim()) ?? 0;
final checksumExpected = _checksumHashController.text.trim();
final checksum = (!omitChecksum && _inputUrls().length <= 1 && checksumExpected.isNotEmpty)
? api_options.ChecksumOption(
algorithm: _checksumAlgorithm,
expected: checksumExpected,
)
: null;
return api_options.Options(
name: name ?? _renameController.text.trim(),
path: _directoryController.text.trim(),
@@ -1036,6 +1105,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
autoExtract: _autoExtract,
archivePassword: _archivePasswordController.text,
deleteAfterExtract: _deleteAfterExtract,
checksum: checksum,
).toJson(),
);
}
@@ -1050,13 +1120,14 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
Future<void> _submitResolved(Request request, ResolveResult result, List<int> selectedIndexes) async {
final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList())
..sort();
final isMultiFile = selected.length > 1;
if (result.id.isNotEmpty) {
await ref
.read(gopeedServiceProvider)
.createTask(
CreateTask(
rid: result.id,
opts: _buildOptions(selectFiles: selected),
opts: _buildOptions(selectFiles: selected, omitChecksum: isMultiFile),
),
);
return;
@@ -1070,11 +1141,11 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
opts: api_options.Options(
name: file.name,
path: path.join(_directoryController.text.trim(), result.res.name, file.path),
extra: _buildOptions().extra,
extra: _buildOptions(omitChecksum: isMultiFile).extra,
),
);
}).toList();
await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions()));
await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions(omitChecksum: isMultiFile)));
return;
}
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "إضافة",
"edit": "تعديل",
"newVersionTitle": "يتوفر إصدار جديد @version",
"newVersionUpdate": "تحديث الآن",
"newVersionUpdate": "تحديث",
"newVersionLater": "لاحقاً",
"extensions": "الإضافات",
"extensionInstallUrl": "عنوان التثبيت",
@@ -164,6 +164,11 @@
"autoExtract": "فك الضغط تلقائياً",
"archivePassword": "كلمة مرور الملف المضغوط",
"archivePasswordHint": "اتركه فارغاً إذا لم يكن محمياً بكلمة مرور",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "حذف الملف المضغوط بعد فك الضغط",
"extracting": "جاري فك الضغط",
"extractDone": "تم فك الضغط بنجاح",
+6 -1
View File
@@ -106,7 +106,7 @@
"add": "Əlavə et",
"edit": "Redaktə et",
"newVersionTitle": "Yeni versiya @version tapıldı",
"newVersionUpdate": "İndi Yenilə",
"newVersionUpdate": "Yenilə",
"newVersionLater": "Sonra",
"extensions": "Uzantılar",
"extensionInstallUrl": "Quraşdırma URL-i",
@@ -171,6 +171,11 @@
"autoExtract": "Arxivləri Avtomatik Çıxar",
"archivePassword": "Arxiv Parolu",
"archivePasswordHint": "Parolla qorunmayıbsa boş buraxın",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Çıxarıldıqdan sonra arxivi sil",
"extracting": "Çıxarılır",
"extractDone": "Çıxarma tamamlandı",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Afegeix",
"edit": "Edita",
"newVersionTitle": "Descobriu la nova versió @version",
"newVersionUpdate": "Actualitza ara",
"newVersionUpdate": "Actualitza",
"newVersionLater": "Més tard",
"extensions": "Extensions",
"extensionInstallUrl": "URL d'instal·lació",
@@ -164,6 +164,11 @@
"autoExtract": "Extreu els comprimits automàticament",
"archivePassword": "Contrasenya del comprimit",
"archivePasswordHint": "Deixeu-lo en blanc si no està protegit amb contrasenya",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Suprimeix el comprimit després de l'extracció",
"extracting": "S'està extraient",
"extractDone": "Extracció completada",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Hinzugefügt",
"edit": "Bearbeiten",
"newVersionTitle": "Entdecken Sie die neue Version @version",
"newVersionUpdate": "Jetzt aktualisieren",
"newVersionUpdate": "Aktualisieren",
"newVersionLater": "Später",
"extensions": "Erweiterungen",
"extensionInstallUrl": "Installations-URL",
@@ -164,6 +164,11 @@
"autoExtract": "Archive automatisch entpacken",
"archivePassword": "Archiv-Passwort",
"archivePasswordHint": "Leer lassen, wenn nicht passwortgeschützt",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Archiv nach Entpacken löschen",
"extracting": "Wird entpackt",
"extractDone": "Entpacken abgeschlossen",
+7 -2
View File
@@ -106,7 +106,7 @@
"add": "Add",
"edit": "Edit",
"newVersionTitle": "Discover new version @version",
"newVersionUpdate": "Update Now",
"newVersionUpdate": "Update",
"newVersionLater": "Later",
"extensions": "Extensions",
"extensionInstallUrl": "Install URL",
@@ -137,7 +137,7 @@
"extensionLoadMore": "Load More",
"extensionNoMore": "No more extensions",
"extensionInstalled": "Installed",
"extensionCanUpdate": "Update Available",
"extensionCanUpdate": "Update",
"extensionInstall": "Install",
"extensionLoadLocal": "Install Local Folder",
"extensionFilterAll": "All",
@@ -178,6 +178,11 @@
"autoExtract": "Extract Archives Automatically",
"archivePassword": "Archive Password",
"archivePasswordHint": "Leave empty if not password protected",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Delete Archives After Extraction",
"extracting": "Extracting",
"extractDone": "Extraction completed",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Añadir",
"edit": "Editar",
"newVersionTitle": "Nueva versión @version disponible",
"newVersionUpdate": "Actualizar Ahora",
"newVersionUpdate": "Actualizar",
"newVersionLater": "Más tarde",
"extensions": "Extensiones",
"extensionInstallUrl": "URL de instalación",
@@ -164,6 +164,11 @@
"autoExtract": "Extraer archivos automáticamente",
"archivePassword": "Contraseña del archivo",
"archivePasswordHint": "Dejar vacío si no tiene contraseña",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Eliminar archivo después de extraer",
"extracting": "Extrayendo",
"extractDone": "Extracción completada",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "افزودن",
"edit": "ویرایش",
"newVersionTitle": "@version عنوان: کشف نسخه جدید",
"newVersionUpdate": "بروزرسانی",
"newVersionUpdate": "به‌روزرسانی",
"newVersionLater": "بعداً",
"extensions": "افزونه‌ها",
"extensionInstallUrl": "نشانی نصب",
@@ -164,6 +164,11 @@
"autoExtract": "استخراج خودکار بایگانی‌ها",
"archivePassword": "رمز عبور بایگانی",
"archivePasswordHint": "اگر رمز ندارد خالی بگذارید",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "حذف آرشیو پس از استخراج",
"extracting": "در حال استخراج",
"extractDone": "استخراج کامل شد",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Ajouter",
"edit": "Modifier",
"newVersionTitle": "Nouvelle version @version disponible",
"newVersionUpdate": "Mettre à jour maintenant",
"newVersionUpdate": "Mettre à jour",
"newVersionLater": "Plus tard",
"extensions": "Extensions",
"extensionInstallUrl": "URL d'installation",
@@ -164,6 +164,11 @@
"autoExtract": "Extraire automatiquement les archives",
"archivePassword": "Mot de passe de l'archive",
"archivePasswordHint": "Laisser vide si non protégé par mot de passe",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Supprimer l'archive après extraction",
"extracting": "Extraction en cours",
"extractDone": "Extraction terminée",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Hozzáad",
"edit": "Szerk",
"newVersionTitle": "Új verzió felfedezése @version",
"newVersionUpdate": "Frissítés most",
"newVersionUpdate": "Frissítés",
"newVersionLater": "Később",
"extensions": "Bővítmények",
"extensionInstallUrl": "Telepítési URL",
@@ -164,6 +164,11 @@
"autoExtract": "Archívumok automatikus kicsomagolása",
"archivePassword": "Archívum jelszó",
"archivePasswordHint": "Hagyja üresen, ha nincs jelszóval védve",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Archívum törlése kicsomagolás után",
"extracting": "Kicsomagolás",
"extractDone": "Kicsomagolás befejezve",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Tambah",
"edit": "Sunting",
"newVersionTitle": "Temukan versi batu @version",
"newVersionUpdate": "Perbarui Sekarang",
"newVersionUpdate": "Perbarui",
"newVersionLater": "Nanti",
"extensions": "Ekstensi",
"extensionInstallUrl": "URL Instal",
@@ -164,6 +164,11 @@
"autoExtract": "Ekstrak Arsip Otomatis",
"archivePassword": "Kata Sandi Arsip",
"archivePasswordHint": "Kosongkan jika tidak dilindungi kata sandi",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Hapus arsip setelah ekstraksi",
"extracting": "Mengekstrak",
"extractDone": "Ekstraksi selesai",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Aggiungi",
"edit": "Modifica",
"newVersionTitle": "Scopri la nuova versione @version",
"newVersionUpdate": "Aggiorna ora",
"newVersionUpdate": "Aggiorna",
"newVersionLater": "Dopo",
"extensions": "Estensioni",
"extensionInstallUrl": "Installa URL",
@@ -164,6 +164,11 @@
"autoExtract": "Estrai automaticamente gli archivi",
"archivePassword": "Password archivio",
"archivePasswordHint": "Lasciare vuoto se non protetto da password",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Elimina l'archivio dopo l'estrazione",
"extracting": "Estrazione in corso",
"extractDone": "Estrazione completata",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "追加",
"edit": "編集",
"newVersionTitle": "新しいバージョン @version を発見する",
"newVersionUpdate": "アップデート",
"newVersionUpdate": "更新",
"newVersionLater": "後で",
"extensions": "拡張機能",
"extensionInstallUrl": "インストールURL",
@@ -164,6 +164,11 @@
"autoExtract": "アーカイブを自動展開",
"archivePassword": "アーカイブパスワード",
"archivePasswordHint": "パスワードなしの場合は空のままにしてください",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "展開後にアーカイブを削除",
"extracting": "展開中",
"extractDone": "展開完了",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "추가",
"edit": "편집",
"newVersionTitle": "새 버전 @version 발견",
"newVersionUpdate": "지금 업데이트",
"newVersionUpdate": "업데이트",
"newVersionLater": "나중에",
"extensions": "확장 프로그램",
"extensionInstallUrl": "설치 URL",
@@ -164,6 +164,11 @@
"autoExtract": "압축 파일 자동 압축 해제",
"archivePassword": "압축 파일 비밀번호",
"archivePasswordHint": "비밀번호가 없으면 비워 두세요",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "압축 해제 후 압축 파일 삭제",
"extracting": "압축 해제 중",
"extractDone": "압축 해제 완료",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Dodaj",
"edit": "Edycja",
"newVersionTitle": "Sprawdź aktualizację @version",
"newVersionUpdate": "Aktualizuj teraz",
"newVersionUpdate": "Aktualizuj",
"newVersionLater": "Później",
"extensions": "Wtyczki",
"extensionInstallUrl": "Instaluj z url",
@@ -164,6 +164,11 @@
"autoExtract": "Automatycznie wyodrębnij archiwa",
"archivePassword": "Hasło archiwum",
"archivePasswordHint": "Pozostaw puste, jeśli nie ma hasła",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Usuń archiwum po rozpakowaniu",
"extracting": "Rozpakowywanie",
"extractDone": "Rozpakowywanie zakończone",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Adicionar",
"edit": "Editar",
"newVersionTitle": "Nova Versão Disponível @version",
"newVersionUpdate": "Atualizar Agora",
"newVersionUpdate": "Atualizar",
"newVersionLater": "Mais Tarde",
"extensions": "Extensões",
"extensionInstallUrl": "Link de Instalação",
@@ -164,6 +164,11 @@
"autoExtract": "Extrair arquivos automaticamente",
"archivePassword": "Senha do arquivo",
"archivePasswordHint": "Deixar vazio se não estiver protegido por senha",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Excluir arquivo após extração",
"extracting": "Extraindo",
"extractDone": "Extração concluída",
+5
View File
@@ -164,6 +164,11 @@
"autoExtract": "Автоматически распаковать архивы",
"archivePassword": "Пароль архива",
"archivePasswordHint": "Оставить пустым, если архив не защищён паролем",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Удалить архив после распаковки",
"extracting": "Распаковка",
"extractDone": "Распаковка завершена",
+5
View File
@@ -178,6 +178,11 @@
"autoExtract": "Automaticky rozbaliť archívy",
"archivePassword": "Heslo archívu",
"archivePasswordHint": "Nechajte prázdne, ak nie je chránený heslom",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Po rozbalení odstrániť archívy",
"extracting": "Rozbaľovanie",
"extractDone": "Rozbalenie dokončené",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "சேர்",
"edit": "திருத்துக",
"newVersionTitle": "புதிய பதிப்பைக் கண்டறியவும் @version",
"newVersionUpdate": "இப்பொழுது புதுப்பி",
"newVersionUpdate": "புதுப்பிப்பு",
"newVersionLater": "பின்னர்",
"extensions": "நீட்டிப்புகள்",
"extensionInstallUrl": "நிறுவப்படும் URL",
@@ -164,6 +164,11 @@
"autoExtract": "காப்பகங்களை தானாக பிரித்தெடு",
"archivePassword": "காப்பக கடவுச்சொல்",
"archivePasswordHint": "கடவுச்சொல் இல்லையென்றால் காலியாக விடவும்",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "பிரித்தெடுத்த பிறகு காப்பகத்தை நீக்கு",
"extracting": "பிரித்தெடுக்கிறது",
"extractDone": "பிரித்தெடுத்தல் முடிந்தது",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Ekle",
"edit": "Düzenle",
"newVersionTitle": "Yeni sürüm @version keşfedildi",
"newVersionUpdate": "Şimdi Güncelle",
"newVersionUpdate": "Güncelle",
"newVersionLater": "Daha Sonra",
"extensions": "Eklentiler",
"extensionInstallUrl": "Kurulum URL'si",
@@ -164,6 +164,11 @@
"autoExtract": "Arşivleri Otomatik Çıkar",
"archivePassword": "Arşiv Şifresi",
"archivePasswordHint": "Şifre korumalı değilse boş bırakın",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Çıkardıktan sonra arşivi sil",
"extracting": "Çıkarılıyor",
"extractDone": "Çıkarma tamamlandı",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Додати",
"edit": "Редагування",
"newVersionTitle": "Доступна нова версія @Version",
"newVersionUpdate": "Оновити зараз",
"newVersionUpdate": "Оновити",
"newVersionLater": "Пізніше",
"extensions": "Розширення",
"extensionInstallUrl": "URL для встановлення",
@@ -164,6 +164,11 @@
"autoExtract": "Автоматично розпаковувати архіви",
"archivePassword": "Пароль архіву",
"archivePasswordHint": "Залишити порожнім, якщо архів не захищений паролем",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Видалити архів після розпакування",
"extracting": "Розпакування",
"extractDone": "Розпакування завершено",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Thêm",
"edit": "Sửa",
"newVersionTitle": "Khám phá phiên bản mới @version",
"newVersionUpdate": "Cập nhật ngay",
"newVersionUpdate": "Cập nhật",
"newVersionLater": "Sau",
"extensions": "Tiện ích mở rộng",
"extensionInstallUrl": "Liên kết cài đặt",
@@ -164,6 +164,11 @@
"autoExtract": "Tự động giải nén tập tin nén",
"archivePassword": "Mật khẩu tập tin nén",
"archivePasswordHint": "Để trống nếu không có mật khẩu",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Xóa tập tin nén sau khi giải nén",
"extracting": "Đang giải nén",
"extractDone": "Giải nén hoàn tất",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "添加",
"edit": "编辑",
"newVersionTitle": "发现新版本 @version",
"newVersionUpdate": "立即更新",
"newVersionUpdate": "更新",
"newVersionLater": "稍后再说",
"extensions": "扩展",
"extensionInstallUrl": "安装链接",
@@ -164,6 +164,11 @@
"autoExtract": "自动解压压缩包",
"archivePassword": "压缩包密码",
"archivePasswordHint": "如无密码请留空",
"checksumAlgorithm": "校验和算法",
"checksumHash": "校验和",
"checksumExpectedHint": "预期哈希值(可选)",
"checksumInvalidLength": "{algorithm} 校验和哈希长度无效:应为 {length} 个字符",
"checksumInvalidHex": "校验和哈希无效:应为十六进制字符(0-9,a-f)",
"deleteAfterExtract": "解压后删除压缩包",
"extracting": "正在解压",
"extractDone": "解压完成",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "新增",
"edit": "編輯",
"newVersionTitle": "發現新版本 @version",
"newVersionUpdate": "立即更新",
"newVersionUpdate": "更新",
"newVersionLater": "稍後",
"extensions": "擴充功能",
"extensionInstallUrl": "安裝 URL",
@@ -164,6 +164,11 @@
"autoExtract": "自動解壓縮壓縮包",
"archivePassword": "壓縮包密碼",
"archivePasswordHint": "如無密碼請留空",
"checksumAlgorithm": "校驗和演算法",
"checksumHash": "校驗和",
"checksumExpectedHint": "預期雜湊值(可選)",
"checksumInvalidLength": "{algorithm} 校驗和雜湊長度無效:應為 {length} 個字元",
"checksumInvalidHex": "校驗和雜湊無效:應為十六進位字元(0-9,a-f)",
"deleteAfterExtract": "解壓後刪除壓縮檔",
"extracting": "正在解壓",
"extractDone": "解壓完成",
@@ -16,6 +16,7 @@ class AppDetailDrawer extends StatelessWidget {
required this.child,
this.drawerKey,
this.width,
this.titleTrailing,
});
final bool open;
@@ -24,6 +25,7 @@ class AppDetailDrawer extends StatelessWidget {
final Widget child;
final Key? drawerKey;
final double? width;
final Widget? titleTrailing;
@override
Widget build(BuildContext context) {
@@ -82,15 +84,25 @@ class AppDetailDrawer extends StatelessWidget {
child: Row(
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: palette.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w700,
),
child: Row(
children: [
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: palette.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
if (titleTrailing != null) ...[
const SizedBox(width: 8),
Flexible(child: titleTrailing!),
],
],
),
),
const SizedBox(width: 12),
@@ -13,12 +13,14 @@ class AppDetailPage extends StatelessWidget {
required this.onBack,
required this.child,
this.backgroundColor,
this.titleTrailing,
});
final String title;
final VoidCallback onBack;
final Widget child;
final Color? backgroundColor;
final Widget? titleTrailing;
@override
Widget build(BuildContext context) {
@@ -53,12 +55,22 @@ class AppDetailPage extends StatelessWidget {
),
),
Expanded(
child: Text(
title,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700),
),
),
if (titleTrailing != null) ...[
const SizedBox(width: 8),
Flexible(child: titleTrailing!),
],
],
),
),
const SizedBox(width: 44),
@@ -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/widgets/app_loading_button.dart';
import 'package:gopeed/shared/widgets/app_tooltip.dart';
import 'package:gopeed/l10n/app_localizations.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
@@ -611,6 +612,355 @@ void main() {
expect(find.byKey(const ValueKey('create-history-clear')), findsOneWidget);
expect(find.byKey(const ValueKey('create-history-close')), findsOneWidget);
});
testWidgets('checksum state is reset for each incoming task and not inherited (T1)', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
tester.view.physicalSize = const Size(700, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
CreateTask? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
submitted = task;
return 'task-id';
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final container = ProviderContainer(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
);
addTearDown(container.dispose);
final pending = container.read(pendingCreateTaskProvider.notifier);
// Task A: with checksum
pending.set(
CreateTask(
req: Request(url: 'https://example.com/taskA.zip'),
opts: Options(
extra: OptsExtraHttp(
checksum: ChecksumOption(
algorithm: 'sha256',
expected: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
),
).toJson(),
),
),
);
final router = GoRouter(
initialLocation: '/create',
routes: [
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: shad.ShadcnApp.router(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
routerConfig: router,
builder: (_, child) => AppComponentThemes(child: child!),
),
),
);
await tester.pumpAndSettle();
// Expand advanced
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
expect(_fieldText(tester, 'create-task-checksum-expected'), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
// Task B form 1: null opts
pending.set(CreateTask(req: Request(url: 'https://example.com/taskB1.zip')));
router.go('/create');
await tester.pumpAndSettle();
expect(_fieldText(tester, 'create-task-checksum-expected'), '');
// Task B form 2: empty Options
pending.set(CreateTask(req: Request(url: 'https://example.com/taskB2.zip'), opts: Options()));
router.go('/create');
await tester.pumpAndSettle();
expect(_fieldText(tester, 'create-task-checksum-expected'), '');
// Task B form 3: HTTP extra with no checksum
pending.set(
CreateTask(
req: Request(url: 'https://example.com/taskB3.zip'),
opts: Options(extra: OptsExtraHttp(connections: 8).toJson()),
),
);
router.go('/create');
await tester.pumpAndSettle();
expect(_fieldText(tester, 'create-task-checksum-expected'), '');
// Submit task B3 and ensure no checksum is sent
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-toggle')));
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted?.opts?.extra, isNotNull);
final extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
expect(extra.checksum, isNull);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('checksum input validation blocks submission on invalid input (T3-UI)', (tester) async {
tester.view.physicalSize = const Size(1024, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
CreateTask? submitted;
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.createTask, (task) {
submitted = task;
return 'task-id';
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final container = ProviderContainer(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
);
addTearDown(container.dispose);
final pending = container.read(pendingCreateTaskProvider.notifier);
pending.set(CreateTask(req: Request(url: 'https://example.com/file.zip')));
final router = GoRouter(
initialLocation: '/create',
routes: [
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: shad.ShadcnApp.router(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
routerConfig: router,
builder: (_, child) => AppComponentThemes(child: child!),
),
),
);
await tester.pumpAndSettle();
// Expand advanced options
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
// Test 1: "abc" for SHA-256
await tester.tap(find.text('SHA-256'));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'abc');
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted, isNull);
// Test 2: 64 x "g" (non-hex) for SHA-256
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'g' * 64);
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted, isNull);
// Test 3: 63 x "a" (invalid length) for SHA-256
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'a' * 63);
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted, isNull);
// Test 4: 65 x "a" (invalid length) for SHA-256
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'a' * 65);
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted, isNull);
// Test 5: MD5 with sha256-length hash
await tester.tap(find.text('MD5'));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'a' * 64);
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted, isNull);
// Test 6: Valid MD5 in UPPER case
submitted = null;
await tester.tap(find.text('MD5'));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), '0123456789ABCDEF0123456789ABCDEF');
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted, isNotNull);
var extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
expect(extra.checksum?.algorithm, 'md5');
expect(extra.checksum?.expected, '0123456789ABCDEF0123456789ABCDEF');
// Test 7: Valid SHA-1 in lower case
submitted = null;
pending.set(CreateTask(req: Request(url: 'https://example.com/file2.zip')));
router.go('/create');
await tester.pumpAndSettle();
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
await tester.tap(find.text('SHA-1'));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'da39a3ee5e6b4b0d3255bfef95601890afd80709');
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted, isNotNull);
extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
expect(extra.checksum?.algorithm, 'sha1');
expect(extra.checksum?.expected, 'da39a3ee5e6b4b0d3255bfef95601890afd80709');
// Test 8: Valid SHA-256 in lower case
submitted = null;
pending.set(CreateTask(req: Request(url: 'https://example.com/file3.zip')));
router.go('/create');
await tester.pumpAndSettle();
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
await tester.tap(find.text('SHA-256'));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted, isNotNull);
extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
expect(extra.checksum?.algorithm, 'sha256');
expect(extra.checksum?.expected, 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
// Test 9: Omitted checksum (empty hash)
submitted = null;
pending.set(CreateTask(req: Request(url: 'https://example.com/file4.zip')));
router.go('/create');
await tester.pumpAndSettle();
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), ' ');
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted, isNotNull);
extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
expect(extra.checksum, isNull);
});
testWidgets('multi-URL and multi-file batch creation omits checksum (T4)', (tester) async {
tester.view.physicalSize = const Size(1024, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final createdTasks = <CreateTask>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
createdTasks.add(task);
return 'task-id';
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final container = ProviderContainer(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
);
addTearDown(container.dispose);
final router = GoRouter(
initialLocation: '/create',
routes: [
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: shad.ShadcnApp.router(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
routerConfig: router,
builder: (_, child) => AppComponentThemes(child: child!),
),
),
);
await tester.pumpAndSettle();
// Enter 2 URLs
await tester.enterText(
find.byKey(const ValueKey('create-task-url-input')),
'https://example.com/file1.zip\nhttps://example.com/file2.zip',
);
await tester.pumpAndSettle();
// Advanced options should hide the checksum field
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('create-task-checksum-expected')), findsNothing);
// Confirm creation
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(createdTasks.length, 2);
for (final t in createdTasks) {
final extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(t.opts!.extra! as Map));
expect(extra.checksum, isNull);
}
});
testWidgets('checksum UI uses localized labels (T5)', (tester) async {
tester.view.physicalSize = const Size(700, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'));
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
child: shad.ShadcnApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(
child: CreateTaskWindowPage(
initialTask: CreateTask(req: Request(url: 'https://example.com/file.zip')),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
expect(find.text('Checksum Algorithm'), findsOneWidget);
expect(find.text('Checksum Hash'), findsOneWidget);
});
}
String _fieldText(WidgetTester tester, String key) {
+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/presentation/pages/extension_details_page.dart';
import 'package:gopeed/features/extensions/presentation/pages/extensions_page.dart';
import 'package:gopeed/features/extensions/presentation/widgets/extension_icon.dart';
import 'package:gopeed/features/home/presentation/widgets/primary_rail.dart';
import 'package:gopeed/features/tasks/application/pending_update_task.dart';
import 'package:gopeed/features/tasks/application/task_batch_selection_controller.dart';
@@ -688,6 +689,30 @@ void main() {
expect(tester.takeException(), isNull);
});
for (final width in [320.0, 1100.0]) {
testWidgets('extension update status keeps the card header at icon height at $width', (tester) async {
await _setTestSize(tester, Size(width, 900));
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(UpdateableExtensionsController.new)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const ExtensionsPage(),
),
),
);
await tester.pumpAndSettle();
final card = find.byKey(const ValueKey('extension-card-extension-0'));
final icon = find.descendant(of: card, matching: find.byType(ExtensionIcon));
final description = find.descendant(of: card, matching: find.text('Installed extension'));
expect(find.byKey(const ValueKey('extension-card-update-status-extension-0')), findsOneWidget);
expect(tester.getRect(description).top - tester.getRect(icon).bottom, closeTo(10, 0.01));
expect(tester.takeException(), isNull);
});
}
testWidgets('extension details reuse the desktop detail drawer', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1100, 900));
final controller = FakeExtensionsController();
@@ -817,7 +842,9 @@ void main() {
await tester.tap(find.byKey(const ValueKey('extension-card-extension-0')));
await tester.pumpAndSettle();
}
final entry = find.byKey(ValueKey(details ? 'extension-details-update' : 'update-extension-extension-0'));
final entry = find.byKey(
ValueKey(details ? 'extension-details-update' : 'extension-card-update-status-extension-0'),
);
final dialog = find.byKey(const ValueKey('extension-update-dialog'));
final confirm = find.byKey(const ValueKey('confirm-update-extension-button'));
final cancel = find.byKey(const ValueKey('cancel-update-extension-button'));
@@ -2327,7 +2354,7 @@ void main() {
final progress = find.descendant(of: updateButton, matching: find.byType(shad.CircularProgressIndicator));
expect(progress, findsOneWidget);
expect(tester.getSize(progress), const Size.square(14));
expect(find.descendant(of: updateButton, matching: find.text('Update Now')), findsOneWidget);
expect(find.descendant(of: updateButton, matching: find.text('Update')), findsOneWidget);
runtimeController.completeTrackerUpdate();
await tester.pumpAndSettle();
+11
View File
@@ -57,6 +57,17 @@ add_subdirectory("runner")
# them to the application.
include(flutter/generated_plugins.cmake)
# Temporary compatibility for legacy coroutine headers on MSVC 14.51.
# Remove once both Windows plugins use standard coroutines.
if(MSVC)
foreach(plugin IN ITEMS flutter_inappwebview_windows_plugin permission_handler_windows_plugin)
if(TARGET ${plugin})
target_compile_definitions(${plugin} PRIVATE
_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS)
endif()
endforeach()
endif()
# === Installation ===
# Support files are copied into place next to the executable, so that it can