Compare commits

..
50 changed files with 1466 additions and 867 deletions

No files matched your search

+33 -22
View File
@@ -620,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)
}
@@ -675,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
@@ -692,6 +708,9 @@ func (f *Fetcher) initOptions() error {
extra.Connections = 1
}
}
if err := extra.Checksum.Validate(); err != nil {
return err
}
return nil
}
@@ -746,8 +765,8 @@ func (f *Fetcher) doStart() error {
// If retrying after error, reset connection states for retry
if state == stateError {
forceFullReset := f.checksumFailed.Swap(false)
if forceFullReset {
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
@@ -755,6 +774,7 @@ func (f *Fetcher) doStart() error {
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 {
@@ -762,10 +782,10 @@ func (f *Fetcher) doStart() error {
// finish. On a checksum mismatch, reset EVERY connection —
// "Completed" only means the download loop finished, not
// that the bytes were verified correct.
shouldReset := forceFullReset || (!conn.Completed && conn.State != connCompleted)
shouldReset := fullRedownload || (!conn.Completed && conn.State != connCompleted)
if shouldReset {
if !f.hasSequentialPrefixLocked(conn) {
f.resetConnectionForRestart(conn, forceFullReset)
if fullRedownload || !f.hasSequentialPrefixLocked(conn) {
f.resetConnectionForRestart(conn)
}
conn.State = connNotStarted
conn.failed = false
@@ -1138,7 +1158,7 @@ func (f *Fetcher) runConnection(conn *connection) {
// it only when no safe validator exists or If-Range returns a full 200.
f.connMu.Lock()
if !f.hasSequentialPrefixLocked(conn) {
f.resetConnectionForRestart(conn, false)
f.resetConnectionForRestart(conn)
}
f.connMu.Unlock()
}
@@ -1245,7 +1265,7 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
resumeProbe := f.canProbeSequentialResumeLocked(conn)
intentionalRestart := sequentialSizeUnknown
if sequentialSizeUnknown || (hasSequentialPrefix && !resumeProbe) {
f.resetConnectionForRestart(conn, false)
f.resetConnectionForRestart(conn)
f.resolveDataPos.Store(0)
f.rangeValidatorPinned = false
intentionalRestart = true
@@ -1690,7 +1710,7 @@ func (f *Fetcher) fallbackToSequentialDownload(conn *connection, ifRange string)
f.rangeReprobeEligible = true
f.rangeValidatorPinned = false
f.ifRange = ifRange
f.resetConnectionForRestart(conn, false)
f.resetConnectionForRestart(conn)
f.resolveDataPos.Store(0)
return nil
}
@@ -1706,7 +1726,7 @@ func (f *Fetcher) restartSequentialDownload(conn *connection, ifRange string) {
f.rangeReprobeEligible = true
f.rangeValidatorPinned = false
f.ifRange = ifRange
f.resetConnectionForRestart(conn, false)
f.resetConnectionForRestart(conn)
f.resolveDataPos.Store(0)
}
@@ -2089,19 +2109,10 @@ func (f *Fetcher) helpOtherConnection(helper *connection) bool {
return true
}
func (f *Fetcher) resetConnectionForRestart(conn *connection, force bool) {
if f.meta.Res.Range && !force {
func (f *Fetcher) resetConnectionForRestart(conn *connection) {
if f.meta.Res.Range {
return
}
if f.meta.Res.Range && force {
// Checksum mismatch: the on-disk content for this chunk is
// confirmed wrong, so its bytes must be re-fetched from the
// origin server even though range support would normally let
// us just resume from the existing Downloaded position.
if conn.Chunk != nil {
conn.Chunk.Downloaded = 0
}
}
// Without range support a new request always starts from byte 0,
// so pause/retry must restart instead of continuing from the old offset.
@@ -2171,7 +2182,7 @@ func (f *Fetcher) resumeConnections() {
}
}
if !f.hasSequentialPrefixLocked(conn) {
f.resetConnectionForRestart(conn, false)
f.resetConnectionForRestart(conn)
}
// Reset the connection state for resume
conn.ctx, conn.cancel = context.WithCancel(f.ctx)
@@ -7,7 +7,9 @@ import (
gohttp "net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -16,29 +18,61 @@ import (
"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!")
correctContent := []byte("Correct Retried Download Data!")
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 reqCount atomic.Int32
var phase atomic.Int32 // 0: serve corruptContent, 1: serve correctContent
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
count := reqCount.Add(1)
var served []byte
if count == 1 {
// First request: serve corrupted content
if phase.Load() == 0 {
served = corruptContent
} else {
// Second / subsequent request: serve correct content
served = correctContent
}
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(served)))
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(served)
serveRangeContent(w, r, served)
}))
defer server.Close()
@@ -64,19 +98,6 @@ func TestFetcher_ChecksumMismatch_Retry(t *testing.T) {
if err := f.Resolve(&base.Request{URL: server.URL + "/retry_test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
// Ensure the reusable resolve response has been fully prefetched so the
// first attempt deterministically verifies the corrupt initial payload.
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for !f.prefetchDone.Load() {
select {
case <-deadline.C:
t.Fatal("timed out waiting for checksum test prefetch")
case <-ticker.C:
}
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
@@ -91,6 +112,9 @@ func TestFetcher_ChecksumMismatch_Retry(t *testing.T) {
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)
@@ -120,3 +144,245 @@ func TestFetcher_ChecksumMismatch_Retry(t *testing.T) {
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)
}
}
+33 -36
View File
@@ -8,9 +8,9 @@ import (
gohttp "net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
@@ -137,15 +137,9 @@ func TestFetcher_ChecksumVerification(t *testing.T) {
},
}
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()
err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts)
if err == nil {
t.Fatal("Wait expected error for unsupported algorithm, got 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)
@@ -274,34 +268,37 @@ func TestFetcher_ChecksumVerification(t *testing.T) {
t.Fatalf("Wait error: %v", err)
}
})
}
func TestResetConnectionForRestart_ForceOnRangeDownload(t *testing.T) {
f := buildFetcher()
f.meta = &fetcher.FetcherMeta{
Res: &base.Resource{
Range: true,
},
}
conn := &connection{
Chunk: newChunk(0, 1024),
Downloaded: 512,
Completed: true,
}
conn.Chunk.Downloaded = 512
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()
// Non-forced reset on range-supported download does not reset Chunk.Downloaded
f.resetConnectionForRestart(conn, false)
if conn.Chunk.Downloaded != 512 {
t.Fatalf("expected Chunk.Downloaded to remain 512 when force is false, got %d", conn.Chunk.Downloaded)
}
f := buildFetcher()
defer f.Close()
// Forced reset on range-supported download resets Chunk.Downloaded to 0
f.resetConnectionForRestart(conn, true)
if conn.Chunk.Downloaded != 0 {
t.Fatalf("expected Chunk.Downloaded to be reset to 0 when force is true, got %d", conn.Chunk.Downloaded)
}
if conn.Completed {
t.Fatal("expected Completed to be false after reset")
}
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())
}
})
}
@@ -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)
}
}
+65
View File
@@ -1,5 +1,11 @@
package http
import (
"encoding/hex"
"fmt"
"strings"
)
type ReqExtra struct {
Method string `json:"method"`
Header map[string]string `json:"header"`
@@ -13,6 +19,65 @@ type ChecksumOption struct {
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
+150 -3
View File
@@ -1,15 +1,162 @@
package http
import "testing"
import (
"strings"
"testing"
)
func TestOptsExtra_Checksum(t *testing.T) {
extra := &OptsExtra{
Checksum: &ChecksumOption{
Algorithm: "sha256",
Expected: "abcdef1234567890",
Expected: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
}
if extra.Checksum.Algorithm != "sha256" || extra.Checksum.Expected != "abcdef1234567890" {
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)
}
}
})
}
}
+1 -1
View File
@@ -126,7 +126,7 @@ Guidelines:
Use the following desktop reference metrics unless a specific screen requires a documented exception:
- Reference window: `1024 x 768`
- Primary rail width: `78px` (reserves space for native macOS traffic lights)
- Primary rail width: `64px`
- Secondary filter sidebar width: `256px`
- Main content header height: `80px`
- Window drag header height on Windows: `30px`
-6
View File
@@ -85,12 +85,6 @@ Appearance synchronization uses `appearance.changed` and currently contains:
- Theme color
- Locale
After successful task creation, a child calls the typed
`NavigationMethods.showDownloadingTasks` capability before closing. The host
routes to the task list and resets its filter to downloading, including any
search or selection on an already-mounted list. This does not show or focus a
hidden main window. Cancellation and failed creation do not request navigation.
State broadcasting must observe the owning Riverpod state centrally. Do not broadcast directly from a settings button callback, because updates can originate from configuration loading, system changes, or future entry points.
## 6. Storage
+12 -49
View File
@@ -11,8 +11,6 @@ class GopeedSiteApi {
static final instance = GopeedSiteApi._();
static const _host = 'gopeed.com';
final _storePages = <String, ({StoreExtensionPage page, DateTime expires})>{};
final _storeRequests = <String, Future<StoreExtensionPage>>{};
Future<List<dynamic>> getReleases({int perPage = 10}) async {
final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()});
@@ -28,50 +26,18 @@ class GopeedSiteApi {
StoreExtensionSort sort = StoreExtensionSort.stars,
StoreSortOrder order = StoreSortOrder.desc,
String? query,
bool forceRefresh = false,
}) async {
final params = {
'page': page.toString(),
'limit': limit.clamp(1, 100).toString(),
'sort': sort.name,
'order': order.name,
'view': 'summary',
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
};
final key = Uri.https(_host, '/api/extensions', params).toString();
final now = DateTime.now();
_storePages.removeWhere((_, entry) => !now.isBefore(entry.expires));
final cached = _storePages[key];
if (!forceRefresh && cached != null) return cached.page;
final pending = _storeRequests[key];
if (pending != null) return pending;
final request = _fetchStorePage(params);
_storeRequests[key] = request;
try {
final result = await request;
if (_storePages.length >= 32) _storePages.remove(_storePages.keys.first);
_storePages[key] = (page: result, expires: DateTime.now().add(const Duration(minutes: 2)));
return result;
} finally {
_storeRequests.remove(key);
}
}
Future<StoreExtensionPage> _fetchStorePage(Map<String, String> params) async {
final json = await _getJson('/api/extensions', queryParameters: params);
return StoreExtensionPage.fromJson(json as Map<String, dynamic>);
}
Future<StoreExtension> getExtension(String id, {String? version}) async {
final json = await _getJsonUri(
Uri(
scheme: 'https',
host: _host,
pathSegments: ['api', 'extensions', id],
queryParameters: {if (version != null && version.isNotEmpty) 'version': version},
),
final json = await _getJson(
'/api/extensions',
queryParameters: {
'page': page.toString(),
'limit': limit.clamp(1, 100).toString(),
'sort': sort.name,
'order': order.name,
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
},
);
return StoreExtension.fromJson(json as Map<String, dynamic>);
return StoreExtensionPage.fromJson(json as Map<String, dynamic>);
}
Future<void> reportExtensionInstall(String id) async {
@@ -83,11 +49,8 @@ class GopeedSiteApi {
);
}
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) {
return _getJsonUri(Uri.https(_host, path, queryParameters));
}
Future<dynamic> _getJsonUri(Uri uri) async {
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) async {
final uri = Uri.https(_host, path, queryParameters);
final Response<String> response = await proxyRequest(uri.toString());
if (response.data == null || response.data!.isEmpty) {
throw Exception('Empty response from $uri');
@@ -64,7 +64,6 @@ class StoreExtension {
final List<String> topics;
final DateTime? createdAt;
final DateTime? updatedAt;
final bool hasDetails;
StoreExtension({
required this.id,
@@ -85,7 +84,6 @@ class StoreExtension {
required this.topics,
this.createdAt,
this.updatedAt,
this.hasDetails = true,
});
factory StoreExtension.fromJson(Map<String, dynamic> json) {
@@ -108,7 +106,6 @@ class StoreExtension {
topics: _parseTopics(json['topics']),
createdAt: _parseDate(json['createdAt']),
updatedAt: _parseDate(json['updatedAt']),
hasDetails: json.containsKey('readme'),
);
}
@@ -3,13 +3,13 @@ import 'dart:collection';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../api/gopeed_site_api.dart';
import '../../../api/model/extension.dart';
import '../../../api/model/install_extension.dart';
import '../../../api/model/store_extension.dart';
import '../../../api/model/switch_extension.dart';
import '../../../api/model/update_extension_settings.dart';
import '../../../core/capabilities/app_capabilities.dart';
import 'store_extension_details_provider.dart';
final extensionsControllerProvider = AsyncNotifierProvider<ExtensionsController, ExtensionsState>(
ExtensionsController.new,
@@ -121,81 +121,63 @@ class ExtensionsState {
class ExtensionsController extends AsyncNotifier<ExtensionsState> {
static const manualInstallBusyKey = '__manual_install__';
int _devModeCount = 0;
int _installedRevision = 0;
int _storeRevision = 0;
Future<void>? _updateCheck;
bool _checkUpdatesAgain = false;
@override
Future<ExtensionsState> build() => _loadInitialData();
Future<ExtensionsState> _loadInitialData({bool forceRefresh = false}) async {
final next = _current.copyWith(loadingInstalled: true, loadingStore: true);
Future<ExtensionsState> build() async {
var next = const ExtensionsState(loadingInstalled: true, loadingStore: true);
state = AsyncValue.data(next);
await Future.wait([
_loadInstalled(refreshUpdates: true, current: next),
_refreshStore(current: _current, forceRefresh: forceRefresh),
]);
return ref.mounted ? _current : next;
final installed = await _loadInstalled(refreshUpdates: true, current: next);
next = state.value ?? installed;
await _refreshStore(current: next);
return state.value ?? next;
}
Future<void> loadInitialData() async {
await _loadInitialData(forceRefresh: true);
state = const AsyncValue.loading();
state = AsyncValue.data(await build());
}
Future<void> loadInstalled({bool refreshUpdates = false}) async {
if (!ref.mounted) return;
await _loadInstalled(refreshUpdates: refreshUpdates, current: _current.copyWith(loadingInstalled: true));
}
Future<void> refreshStore() async {
await _refreshStore(current: _current.copyWith(loadingStore: true), forceRefresh: true);
await _refreshStore(current: _current.copyWith(loadingStore: true));
}
Future<void> loadMoreStore() async {
final current = _current;
final pagination = current.storePagination;
if (pagination == null || !pagination.hasNext || current.loadingMoreStore) return;
final revision = _storeRevision;
state = AsyncValue.data(current.copyWith(loadingMoreStore: true));
try {
final page = await ref
.read(gopeedSiteApiProvider)
.getExtensions(
page: pagination.page + 1,
limit: pagination.limit,
sort: current.storeSort,
query: current.storeQuery,
);
if (!ref.mounted || revision != _storeRevision) return;
final page = await GopeedSiteApi.instance.getExtensions(
page: pagination.page + 1,
limit: pagination.limit,
sort: current.storeSort,
query: current.storeQuery,
);
state = AsyncValue.data(
_current.copyWith(
// Cached pages can briefly overlap when stars/install counts change.
storeExtensions: {
for (final extension in _current.storeExtensions) extension.id: extension,
for (final extension in page.data) extension.id: extension,
}.values.toList(),
storeExtensions: [..._current.storeExtensions, ...page.data],
storePagination: page.pagination,
loadingMoreStore: false,
),
);
} catch (_) {
if (!ref.mounted || revision != _storeRevision) return;
state = AsyncValue.data(_current.copyWith(loadingMoreStore: false));
rethrow;
}
}
Future<void> searchStore(String query) async {
if (query.trim() == _current.storeQuery) return;
state = AsyncValue.data(_current.copyWith(storeQuery: query.trim()));
await _refreshStore(current: _current);
await refreshStore();
}
Future<void> changeSort(StoreExtensionSort sort) async {
if (sort == _current.storeSort) return;
state = AsyncValue.data(_current.copyWith(storeSort: sort));
await _refreshStore(current: _current);
await refreshStore();
}
void changeFilter(ExtensionListFilter filter) {
@@ -275,43 +257,23 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
});
}
Future<void> checkUpdate() {
if (!ref.mounted) return Future.value();
_checkUpdatesAgain = true;
// Installation/refresh can request a fresh scan while one is running.
// Coalesce those requests instead of launching overlapping Git operations.
return _updateCheck ??= _runUpdateChecks().whenComplete(() => _updateCheck = null);
}
Future<void> _runUpdateChecks() async {
final service = ref.read(gopeedServiceProvider);
while (ref.mounted && _checkUpdatesAgain) {
_checkUpdatesAgain = false;
final revision = _installedRevision;
final installed = List<Extension>.of(_current.installedExtensions);
final flags = <String, String>{};
for (final ext in installed) {
if (!ref.mounted) return;
if (revision != _installedRevision) break;
try {
final resp = await service.upgradeCheckExtension(ext.identity);
if (!ref.mounted) return;
if (revision != _installedRevision) break;
if (resp.newVersion.isNotEmpty) flags[ext.identity] = resp.newVersion;
} catch (_) {
// A failed update check must never prevent loading the store.
Future<void> checkUpdate() async {
final flags = <String, String>{};
for (final ext in _current.installedExtensions) {
try {
final resp = await ref.read(gopeedServiceProvider).upgradeCheckExtension(ext.identity);
if (resp.newVersion.isNotEmpty) {
flags[ext.identity] = resp.newVersion;
}
}
if (ref.mounted && revision == _installedRevision) {
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
}
} catch (_) {}
}
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
}
void tryOpenDevMode() {
if (_devModeCount == 0) {
Future.delayed(const Duration(seconds: 2), () {
if (!ref.mounted || _current.devMode) return;
if (_current.devMode) return;
_devModeCount = 0;
});
}
@@ -324,40 +286,26 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
ExtensionsState get _current => state.value ?? const ExtensionsState();
Future<ExtensionsState> _loadInstalled({required bool refreshUpdates, required ExtensionsState current}) async {
final revision = ++_installedRevision;
state = AsyncValue.data(current.copyWith(loadingInstalled: true));
final installed = await ref.read(gopeedServiceProvider).getExtensions();
if (!ref.mounted) return current;
if (revision != _installedRevision) return _current;
state = AsyncValue.data(_current.copyWith(installedExtensions: installed, loadingInstalled: false));
if (refreshUpdates) {
unawaited(checkUpdate());
await checkUpdate();
}
return _current;
}
Future<void> _refreshStore({required ExtensionsState current, bool forceRefresh = false}) async {
final revision = ++_storeRevision;
state = AsyncValue.data(current.copyWith(loadingStore: true, loadingMoreStore: false));
try {
final page = await ref
.read(gopeedSiteApiProvider)
.getExtensions(
page: 1,
limit: 20,
sort: current.storeSort,
query: current.storeQuery,
forceRefresh: forceRefresh,
);
if (!ref.mounted || revision != _storeRevision) return;
state = AsyncValue.data(
_current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false),
);
} catch (_) {
if (!ref.mounted || revision != _storeRevision) return;
state = AsyncValue.data(_current.copyWith(loadingStore: false));
rethrow;
}
Future<void> _refreshStore({required ExtensionsState current}) async {
state = AsyncValue.data(current.copyWith(loadingStore: true));
final page = await GopeedSiteApi.instance.getExtensions(
page: 1,
limit: 20,
sort: _current.storeSort,
query: _current.storeQuery,
);
state = AsyncValue.data(
_current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false),
);
}
Future<void> _runBusy(String id, Future<void> Function() action) async {
@@ -366,15 +314,12 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
try {
await action();
} finally {
if (ref.mounted) {
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
}
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
}
}
void _bumpStoreInstallCount(String id) {
if (!ref.mounted) return;
final current = _current;
final index = current.storeExtensions.indexWhere((e) => e.id == id);
if (index < 0) return;
@@ -399,17 +344,14 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
topics: ext.topics,
createdAt: ext.createdAt,
updatedAt: ext.updatedAt,
hasDetails: ext.hasDetails,
);
state = AsyncValue.data(current.copyWith(storeExtensions: updated));
}
void _reportInstallSafe(String id) {
if (!ref.mounted) return;
final siteApi = ref.read(gopeedSiteApiProvider);
unawaited(() async {
try {
await siteApi.reportExtensionInstall(id);
await GopeedSiteApi.instance.reportExtensionInstall(id);
} catch (_) {}
}());
}
@@ -1,25 +0,0 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../api/gopeed_site_api.dart';
import '../../../api/model/store_extension.dart';
final gopeedSiteApiProvider = Provider<GopeedSiteApi>((ref) => GopeedSiteApi.instance);
typedef StoreExtensionDetailsKey = ({String id, String version});
// A shared future deduplicates drawer/page requests. Successful details survive
// closing the view, while failures can be retried and new versions get a new key.
final storeExtensionDetailsProvider = FutureProvider.autoDispose.family<StoreExtension, StoreExtensionDetailsKey>((
ref,
key,
) async {
final extension = await ref.watch(gopeedSiteApiProvider).getExtension(key.id, version: key.version);
if (ref.mounted) {
final keepAlive = ref.keepAlive();
final expiry = Timer(const Duration(minutes: 30), keepAlive.close);
ref.onDispose(expiry.cancel);
}
return extension;
});
@@ -30,8 +30,7 @@ import '../widgets/extension_setting_field.dart';
import '../widgets/extension_update_dialog.dart';
import '../widgets/extension_update_status.dart';
// Keep three columns in the 1024px reference window with the wider primary rail.
const _extensionCardMinWidth = 280.0;
const _extensionCardMinWidth = 290.0;
const _extensionGridSpacing = 10.0;
int _extensionGridColumnCount(double width) {
@@ -19,7 +19,6 @@ import '../../../../shared/widgets/app_toast.dart';
import '../../../../shared/widgets/detail/app_detail_surface.dart';
import '../../../../util/util.dart';
import '../../application/extensions_controller.dart';
import '../../application/store_extension_details_provider.dart';
import 'extension_icon.dart';
import 'extension_update_dialog.dart';
import 'extension_update_status.dart';
@@ -64,13 +63,7 @@ class ExtensionDetailView extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final palette = AppPalette.of(context);
final state = ref.watch(extensionsControllerProvider).value;
final summary = state?.findItem(item.id) ?? item;
final summaryStore = summary.store;
final detailKey = summaryStore == null || summaryStore.hasDetails
? null
: (id: summaryStore.id, version: summaryStore.version);
final details = detailKey == null ? null : ref.watch(storeExtensionDetailsProvider(detailKey));
final current = ExtensionListItem(installed: summary.installed, store: details?.value ?? summaryStore);
final current = state?.findItem(item.id) ?? item;
final installed = current.installed;
final store = current.store;
final busy = state?.busyExtensionIds.contains(current.id) ?? false;
@@ -123,7 +116,7 @@ class ExtensionDetailView extends ConsumerWidget {
'v${current.version}',
style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600),
),
if (summaryStore != null) _ExtensionStats(store: summaryStore),
if (store != null) _ExtensionStats(store: store),
],
),
],
@@ -171,22 +164,13 @@ class ExtensionDetailView extends ConsumerWidget {
),
],
),
if (details?.isLoading == true)
const Padding(
key: ValueKey('extension-details-loading'),
padding: EdgeInsets.only(top: 24),
child: Center(child: shad.CircularProgressIndicator()),
),
if (details?.hasError == true && detailKey != null)
Padding(
padding: const EdgeInsets.only(top: 24),
child: shad.SecondaryButton(
key: const ValueKey('extension-details-retry'),
onPressed: () => ref.invalidate(storeExtensionDetailsProvider(detailKey)),
child: Text(context.l10n.retry),
),
),
if (store != null) ...[SizedBox(height: mobile ? 24 : 28), _ExtensionReadme(item: current, mobile: mobile)],
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,
),
],
);
}
@@ -204,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;
@@ -214,7 +198,7 @@ class _ExtensionReadme extends StatefulWidget {
}
class _ExtensionReadmeState extends State<_ExtensionReadme> {
late Future<_ReadmeInfo> _readme;
late final _ReadmeInfo _readme;
@override
void initState() {
@@ -222,81 +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 ||
oldWidget.item.id != widget.item.id ||
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),
),
);
},
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),
);
},
),
],
),
);
}
}
@@ -403,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);
@@ -2128,7 +2128,7 @@ class _BrowserExtensionLinks extends StatelessWidget {
children: [
const Icon(Icons.open_in_new, size: 15),
const SizedBox(width: AppDesignTokens.space8),
Flexible(child: Text(link.label, maxLines: 1, overflow: TextOverflow.ellipsis)),
Text(link.label),
],
),
),
@@ -75,7 +75,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
Map<String, String>? _initialLabels;
RequestProxyMode _proxyMode = RequestProxyMode.follow;
String _proxyScheme = 'http';
String _checksumAlgorithm = 'sha256';
String _checksumAlgorithm = 'md5';
bool _skipVerifyCert = false;
bool? _autoTorrent;
bool? _deleteTorrentAfterDownload;
@@ -187,6 +187,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) {
_fileDataUri = '';
}
if (_inputUrls().length > 1) {
_checksumHashController.clear();
_checksumAlgorithm = 'md5';
}
}
_recognizeMagnetUri(urlText.trim());
}
@@ -196,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();
@@ -677,32 +683,34 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
),
),
],
const SizedBox(height: 14),
AppFormPair(
direction: stacked ? Axis.vertical : Axis.horizontal,
firstLabel: 'Checksum Algorithm',
secondLabel: 'Checksum Hash',
firstFlex: 382,
secondFlex: 618,
first: AppChoiceSegmentedControl<String>(
key: const ValueKey('create-task-checksum-algorithm'),
showIcons: false,
value: _checksumAlgorithm,
buttonKeyPrefix: 'create-task-checksum-algorithm',
alignment: WrapAlignment.start,
options: const [
AppChoiceOption(value: 'md5', label: 'MD5', icon: Icons.tag),
AppChoiceOption(value: 'sha1', label: 'SHA-1', icon: Icons.tag),
AppChoiceOption(value: 'sha256', label: 'SHA-256', icon: Icons.tag),
],
onChanged: (value) => setState(() => _checksumAlgorithm = value),
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,
),
),
second: _WindowTextField(
key: const ValueKey('create-task-checksum-expected'),
controller: _checksumHashController,
hintText: 'Expected hash (optional)',
),
),
],
] else ...[
AppFormRow(
direction: stacked ? Axis.vertical : Axis.horizontal,
@@ -985,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;
}
@@ -1057,10 +1084,10 @@ 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 = checksumExpected.isNotEmpty
final checksum = (!omitChecksum && _inputUrls().length <= 1 && checksumExpected.isNotEmpty)
? api_options.ChecksumOption(
algorithm: _checksumAlgorithm,
expected: checksumExpected,
@@ -1093,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;
@@ -1113,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;
}
@@ -1,50 +0,0 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter/widgets.dart' as flutter;
import '../../../../l10n/l10n.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/widgets/app_loading_button.dart';
class ResolveTaskActions extends StatelessWidget {
const ResolveTaskActions({super.key, required this.submitting, required this.onCancel, required this.onCreate});
final bool submitting;
final VoidCallback onCancel;
final VoidCallback onCreate;
@override
Widget build(BuildContext context) {
// Size both actions from the wider translated label, including while the
// create button replaces its label with a loader.
return IntrinsicWidth(
child: flutter.Row(
mainAxisSize: MainAxisSize.min,
children: [
flutter.Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: AppDesignTokens.dialogActionMinWidth),
child: SecondaryButton(
key: const ValueKey('resolve-cancel-button'),
onPressed: submitting ? null : onCancel,
child: Text(context.l10n.cancel, maxLines: 1, softWrap: false),
),
),
),
const SizedBox(width: AppDesignTokens.space8),
flutter.Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: AppDesignTokens.dialogActionMinWidth),
child: AppLoadingButton(
key: const ValueKey('resolve-create-button'),
onPressed: onCreate,
loading: submitting,
variant: AppLoadingButtonVariant.primary,
child: Text(context.l10n.createAction, maxLines: 1, softWrap: false),
),
),
),
],
),
);
}
}
+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
@@ -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ı",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+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
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+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
@@ -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
@@ -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",
+5
View File
@@ -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é",
+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
@@ -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ı",
+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
@@ -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",
+5
View File
@@ -164,6 +164,11 @@
"autoExtract": "自动解压压缩包",
"archivePassword": "压缩包密码",
"archivePasswordHint": "如无密码请留空",
"checksumAlgorithm": "校验和算法",
"checksumHash": "校验和",
"checksumExpectedHint": "预期哈希值(可选)",
"checksumInvalidLength": "{algorithm} 校验和哈希长度无效:应为 {length} 个字符",
"checksumInvalidHex": "校验和哈希无效:应为十六进制字符(0-9,a-f)",
"deleteAfterExtract": "解压后删除压缩包",
"extracting": "正在解压",
"extractDone": "解压完成",
+5
View File
@@ -164,6 +164,11 @@
"autoExtract": "自動解壓縮壓縮包",
"archivePassword": "壓縮包密碼",
"archivePasswordHint": "如無密碼請留空",
"checksumAlgorithm": "校驗和演算法",
"checksumHash": "校驗和",
"checksumExpectedHint": "預期雜湊值(可選)",
"checksumInvalidLength": "{algorithm} 校驗和雜湊長度無效:應為 {length} 個字元",
"checksumInvalidHex": "校驗和雜湊無效:應為十六進位字元(0-9,a-f)",
"deleteAfterExtract": "解壓後刪除壓縮檔",
"extracting": "正在解壓",
"extractDone": "解壓完成",
@@ -5,8 +5,7 @@ class AppDesignTokens {
static const double windowHeaderHeight = 30;
static const double windowRadius = 12;
// Leave room for native macOS traffic lights while keeping rail widths consistent across platforms.
static const double railWidth = 78;
static const double railWidth = 68;
static const double filterSidebarWidth = 192;
static const double contentHeaderHeight = 48;
static const double taskRowHeight = 72;
@@ -1,78 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/api/gopeed_site_api.dart';
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
void main() {
test('summary requests are shared and cached; explicit refresh requests fresh data', () async {
final pending = Completer<Response<String>>();
final transport = _Transport()..onProxy = (_) => pending.future;
api.setTransportForTesting(transport);
final site = GopeedSiteApi.instance;
final first = site.getExtensions(query: 'cache-test', forceRefresh: true);
final second = site.getExtensions(query: 'cache-test');
expect(transport.urls.length, 1);
expect(Uri.parse(transport.urls.single).queryParameters['view'], 'summary');
pending.complete(
_response({
'data': [
{'id': 'test'},
],
}),
);
expect((await first).data.single.hasDetails, isFalse);
await second;
await site.getExtensions(query: 'cache-test');
expect(transport.urls.length, 1);
await site.getExtensions(query: 'cache-test', forceRefresh: true);
expect(transport.urls.length, 2);
});
test('failed lists are not cached', () async {
final transport = _Transport()..onProxy = (_) => Future.error(StateError('offline'));
api.setTransportForTesting(transport);
final site = GopeedSiteApi.instance;
await expectLater(site.getExtensions(query: 'error-test'), throwsStateError);
transport.onProxy = (_) async => _response({
'data': [
{'id': 'recovered'},
],
});
expect((await site.getExtensions(query: 'error-test')).data.single.id, 'recovered');
expect(transport.urls.length, 2);
});
test('detail IDs are encoded once and a null README is still loaded detail', () async {
final transport = _Transport()..onProxy = (_) async => _response({'id': '开发者@name', 'readme': null});
api.setTransportForTesting(transport);
final result = await GopeedSiteApi.instance.getExtension('开发者@name', version: '1.2.3');
final uri = Uri.parse(transport.urls.single);
expect(uri.pathSegments, ['api', 'extensions', '开发者@name']);
expect(uri.queryParameters['version'], '1.2.3');
expect(result.hasDetails, isTrue);
});
}
Response<String> _response(Map<String, dynamic> json) => Response(
requestOptions: RequestOptions(path: '/test'),
data: jsonEncode(json),
statusCode: 200,
);
class _Transport implements GopeedTransport {
final urls = <String>[];
Future<Response<String>> Function(String) onProxy = (_) async => _response({'data': []});
@override
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
urls.add(uri);
return onProxy(uri);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
@@ -9,9 +9,9 @@ void main() {
test('create-task child window has a localized native title', () {
expect(
AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('en')),
'Gopeed - Create Task',
'Gopeed-Create Task',
);
expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), 'Gopeed - 创建任务');
expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), 'Gopeed-创建任务');
});
test('create-task payload only carries initial task data', () {
@@ -1,234 +0,0 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/gopeed_site_api.dart';
import 'package:gopeed/api/model/extension.dart';
import 'package:gopeed/api/model/store_extension.dart';
import 'package:gopeed/api/model/update_check_extension_resp.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/features/extensions/application/extensions_controller.dart';
import 'package:gopeed/features/extensions/application/store_extension_details_provider.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('store loads while installed data and update checks are still pending', () async {
final installed = Completer<List<Extension>>();
final update = Completer<UpdateCheckExtensionResp>();
var updateCalls = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getExtensions, (_) => installed.future)
..bind(GopeedMethods.checkExtensionUpdate, (_) {
updateCalls++;
return update.future;
});
final site = _SiteApi();
final container = _container(registry, site);
final subscription = container.listen(extensionsControllerProvider, (_, _) {});
addTearDown(subscription.close);
await _settle();
expect(site.listCalls, 1);
expect(container.read(extensionsControllerProvider).value?.storeExtensions.single.id, 'market');
expect(container.read(extensionsControllerProvider).value?.loadingStore, isFalse);
expect(updateCalls, 0);
installed.complete([_installed()]);
await _settle();
expect(updateCalls, 1);
expect(container.read(extensionsControllerProvider).value?.loadingInstalled, isFalse);
expect(container.read(extensionsControllerProvider).value?.storeExtensions.single.id, 'market');
update.complete(UpdateCheckExtensionResp(newVersion: '2.0.0'));
await _settle();
expect(container.read(extensionsControllerProvider).value?.updateFlags, {'gopeed@test': '2.0.0'});
});
test('a stale update scan cannot restore flags after installed data changes', () async {
var installed = [_installed()];
final update = Completer<UpdateCheckExtensionResp>();
var updateCalls = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getExtensions, (_) => installed)
..bind(GopeedMethods.checkExtensionUpdate, (_) {
updateCalls++;
return update.future;
});
final container = _container(registry, _SiteApi());
final controller = container.read(extensionsControllerProvider.notifier);
await _settle();
final running = controller.checkUpdate();
expect(updateCalls, 1);
installed = [];
await controller.loadInstalled();
update.complete(UpdateCheckExtensionResp(newVersion: '2.0.0'));
await running;
expect(container.read(extensionsControllerProvider).value?.installedExtensions, isEmpty);
expect(container.read(extensionsControllerProvider).value?.updateFlags, isEmpty);
});
test('an update scan can finish after the controller is disposed', () async {
final update = Completer<UpdateCheckExtensionResp>();
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getExtensions, (_) => [_installed()])
..bind(GopeedMethods.checkExtensionUpdate, (_) => update.future);
final container = _container(registry, _SiteApi());
final controller = container.read(extensionsControllerProvider.notifier);
await _settle();
final running = controller.checkUpdate();
container.dispose();
update.complete(UpdateCheckExtensionResp(newVersion: '2.0.0'));
await running;
});
test('old search results cannot replace a newer search', () async {
final old = Completer<StoreExtensionPage>();
final latest = Completer<StoreExtensionPage>();
final site = _SiteApi()
..onList = (query) => switch (query) {
'old' => old.future,
'new' => latest.future,
_ => Future.value(_page('market')),
};
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getExtensions, (_) => <Extension>[]);
final container = _container(registry, site);
final controller = container.read(extensionsControllerProvider.notifier);
await _settle();
final first = controller.searchStore('old');
final second = controller.searchStore('new');
latest.complete(_page('new-result'));
await second;
old.complete(_page('old-result'));
await first;
final state = container.read(extensionsControllerProvider).value!;
expect(state.storeQuery, 'new');
expect(state.storeExtensions.single.id, 'new-result');
expect(state.loadingStore, isFalse);
});
test('overlapping cached pages do not duplicate an extension', () async {
final site = _SiteApi();
site.onList = (_) async => StoreExtensionPage.fromJson({
'data': [
{'id': 'market', 'version': '1.0.0'},
if (site.listCalls > 1) {'id': 'second', 'version': '1.0.0'},
],
'pagination': {'page': site.listCalls, 'limit': 20, 'hasNext': site.listCalls == 1},
});
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getExtensions, (_) => <Extension>[]);
final container = _container(registry, site);
final controller = container.read(extensionsControllerProvider.notifier);
await _settle();
await controller.loadMoreStore();
expect(container.read(extensionsControllerProvider).value?.storeExtensions.map((e) => e.id), ['market', 'second']);
});
test('detail requests are shared, cache empty READMEs, and distinguish versions', () async {
final detail = Completer<StoreExtension>();
final site = _SiteApi()..onDetail = (_, _) => detail.future;
final container = ProviderContainer(overrides: [gopeedSiteApiProvider.overrideWithValue(site)]);
addTearDown(container.dispose);
const key = (id: 'market', version: '1.0.0');
final provider = storeExtensionDetailsProvider(key);
final subscription = container.listen(provider, (_, _) {});
final first = container.read(provider.future);
final second = container.read(provider.future);
expect(site.detailCalls, 1);
detail.complete(StoreExtension.fromJson({'id': 'market', 'version': '1.0.0', 'readme': null}));
expect((await first).hasDetails, isTrue);
await second;
subscription.close();
await _settle();
await container.read(provider.future);
expect(site.detailCalls, 1);
await container.read(storeExtensionDetailsProvider((id: 'market', version: '2.0.0')).future);
expect(site.detailCalls, 2);
});
test('failed details can be retried', () async {
final site = _SiteApi()..onDetail = (_, _) => Future.error(StateError('offline'));
final container = ProviderContainer(overrides: [gopeedSiteApiProvider.overrideWithValue(site)]);
addTearDown(container.dispose);
final provider = storeExtensionDetailsProvider((id: 'market', version: '1.0.0'));
final subscription = container.listen(provider, (_, _) {});
addTearDown(subscription.close);
await expectLater(container.read(provider.future), throwsStateError);
site.onDetail = (_, _) async => StoreExtension.fromJson({'id': 'market', 'readme': 'loaded'});
container.invalidate(provider);
expect((await container.read(provider.future)).readme, 'loaded');
expect(site.detailCalls, 2);
});
}
ProviderContainer _container(CapabilityRegistry registry, _SiteApi site) {
final container = ProviderContainer(
overrides: [
appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))),
gopeedSiteApiProvider.overrideWithValue(site),
],
);
addTearDown(container.dispose);
return container;
}
Future<void> _settle() async {
for (var i = 0; i < 5; i++) {
await Future<void>.delayed(Duration.zero);
}
}
StoreExtensionPage _page(String id) => StoreExtensionPage.fromJson({
'data': [
{'id': id, 'version': '1.0.0'},
],
'pagination': {'page': 1, 'limit': 20, 'total': 1, 'totalPages': 1},
});
Extension _installed() => Extension(
identity: 'gopeed@test',
name: 'test',
author: 'gopeed',
title: 'Test',
description: '',
icon: '',
version: '1.0.0',
homepage: '',
repository: null,
disabled: false,
devMode: false,
devPath: '',
);
class _SiteApi implements GopeedSiteApi {
var listCalls = 0;
var detailCalls = 0;
Future<StoreExtensionPage> Function(String?) onList = (_) async => _page('market');
Future<StoreExtension> Function(String, String?) onDetail = (id, _) async =>
StoreExtension.fromJson({'id': id, 'readme': null});
@override
Future<StoreExtensionPage> getExtensions({
int page = 1,
int limit = 20,
StoreExtensionSort sort = StoreExtensionSort.stars,
StoreSortOrder order = StoreSortOrder.desc,
String? query,
bool forceRefresh = false,
}) {
listCalls++;
return onList(query);
}
@override
Future<StoreExtension> getExtension(String id, {String? version}) {
detailCalls++;
return onDetail(id, version);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
@@ -1,89 +0,0 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/gopeed_site_api.dart';
import 'package:gopeed/api/model/store_extension.dart';
import 'package:gopeed/features/extensions/application/extensions_controller.dart';
import 'package:gopeed/features/extensions/application/store_extension_details_provider.dart';
import 'package:gopeed/features/extensions/presentation/widgets/extension_detail_view.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
for (final width in [390.0, 1024.0]) {
testWidgets('summary details load and can retry at width $width', (tester) async {
tester.view.physicalSize = Size(width, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final pending = Completer<StoreExtension>();
final site = _DetailApi()..request = pending.future;
await tester.pumpWidget(
ProviderScope(
overrides: [
extensionsControllerProvider.overrideWith(_SummaryController.new),
gopeedSiteApiProvider.overrideWithValue(site),
],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: shad.Scaffold(
child: ExtensionDetailView(
item: ExtensionListItem(store: _summary()),
mobile: width < 600,
),
),
),
),
);
await tester.pump();
expect(find.byKey(const ValueKey('extension-details-loading')), findsOneWidget);
expect(find.byKey(const ValueKey('extension-details-install')), findsOneWidget);
pending.completeError(StateError('offline'));
await tester.pump();
await tester.pump();
expect(find.byKey(const ValueKey('extension-details-retry')), findsOneWidget);
site.request = Future.value(
StoreExtension.fromJson({
'id': 'market',
'title': 'Market',
'version': '1.0.0',
'readme': '# Loaded details',
'homepage': 'https://example.com',
}),
);
await tester.tap(find.byKey(const ValueKey('extension-details-retry')));
await tester.pumpAndSettle();
expect(find.text('Loaded details'), findsOneWidget);
expect(find.byKey(const ValueKey('extension-details-homepage')), findsOneWidget);
expect(find.byKey(const ValueKey('extension-details-retry')), findsNothing);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
});
}
}
StoreExtension _summary() => StoreExtension.fromJson({
'id': 'market',
'title': 'Market',
'version': '1.0.0',
'repoUrl': 'https://github.com/test/repo',
});
class _SummaryController extends ExtensionsController {
@override
Future<ExtensionsState> build() async => ExtensionsState(storeExtensions: [_summary()]);
}
class _DetailApi implements GopeedSiteApi {
late Future<StoreExtension> request;
@override
Future<StoreExtension> getExtension(String id, {String? version}) => request;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
@@ -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) {
+1 -1
View File
@@ -194,7 +194,7 @@ void main() {
expect(find.text('Faster downloads'), findsOneWidget);
expect(find.text('下载更快'), findsNothing);
expect(find.text('Later'), findsOneWidget);
expect(find.text('Update'), findsOneWidget);
expect(find.text('Update Now'), findsOneWidget);
expect(find.text("What's New"), findsNothing);
final versionText = tester.widget<Text>(find.text('2.0.0-beta.1 → 2.0.0-beta.2'));
expect(versionText.style?.decoration, TextDecoration.none);
+6 -7
View File
@@ -630,9 +630,9 @@ void main() {
tester.view.physicalSize = const Size(1024, 768);
await tester.pumpAndSettle();
expect(gridColumns(), 3);
expect(tester.getSize(find.byKey(const ValueKey('extension-card-extension-0'))).width, greaterThanOrEqualTo(280));
expect(tester.getSize(find.byKey(const ValueKey('extension-card-extension-0'))).width, greaterThanOrEqualTo(290));
tester.view.physicalSize = const Size(1024, 608);
tester.view.physicalSize = const Size(1028, 608);
await tester.pumpAndSettle();
expect(gridColumns(), 3);
expect(tester.getSize(find.byKey(const ValueKey('extension-search-field-container'))).width, 240);
@@ -644,7 +644,6 @@ void main() {
tester.view.physicalSize = const Size(720, 608);
await tester.pumpAndSettle();
expect(find.byType(PrimaryRail), findsOneWidget);
expect(gridColumns(), 2);
final compactSearchRect = tester.getRect(find.byKey(const ValueKey('extension-search-field-container')));
expect(compactSearchRect.width, lessThan(240));
expect(compactSearchRect.height, closeTo(sortRect.height, 0.01));
@@ -666,7 +665,7 @@ void main() {
});
testWidgets('extension cards compact large counts without crowding their actions', (WidgetTester tester) async {
await _setTestSize(tester, const Size(720, 608));
await _setTestSize(tester, const Size(722, 608));
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(CrowdedExtensionCardController.new)],
@@ -684,7 +683,7 @@ void main() {
final cardRect = tester.getRect(find.byKey(const ValueKey('extension-card-crowded-extension')));
final statsRect = tester.getRect(find.byKey(const ValueKey('extension-card-stats-crowded-extension')));
final actionsRect = tester.getRect(find.byKey(const ValueKey('extension-card-actions-crowded-extension')));
expect(cardRect.width, closeTo(284, 0.01));
expect(cardRect.width, closeTo(290, 0.01));
expect(statsRect.right, lessThanOrEqualTo(actionsRect.left - 8));
expect(actionsRect.right, lessThanOrEqualTo(cardRect.right - 14));
expect(tester.takeException(), isNull);
@@ -2677,8 +2676,8 @@ void main() {
await tester.tap(find.byKey(const ValueKey('settings-language-select')));
await tester.pumpAndSettle();
expect(find.text('English'), findsOneWidget);
expect(SettingsLanguageSelect.supportedValues, hasLength(23));
expect(SettingsLanguageSelect.supportedValues, containsAll(<String>['az', 'zh', 'zh_TW', 'pt']));
expect(SettingsLanguageSelect.supportedValues, hasLength(22));
expect(SettingsLanguageSelect.supportedValues, containsAll(<String>['zh', 'zh_TW', 'pt']));
expect(SettingsLanguageSelect.supportedValues, isNot(contains('zh_CN')));
expect(SettingsLanguageSelect.supportedValues, isNot(contains('pt_BR')));
expect(supportedLocaleFromConfig('zh_CN'), isNull);
+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