mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-10 10:30:28 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c03132d423 | ||
|
|
128fada585 | ||
|
|
7dd704fc71 | ||
|
|
6f20790b17 | ||
|
|
fa622a4063 | ||
|
|
c6619794fc | ||
|
|
87a8f87b70 |
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+50
-99
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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": "تم فك الضغط بنجاح",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "استخراج کامل شد",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "展開完了",
|
||||
|
||||
@@ -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": "압축 해제 완료",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Распаковка завершена",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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": "பிரித்தெடுத்தல் முடிந்தது",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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": "Розпакування завершено",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
"autoExtract": "自动解压压缩包",
|
||||
"archivePassword": "压缩包密码",
|
||||
"archivePasswordHint": "如无密码请留空",
|
||||
"checksumAlgorithm": "校验和算法",
|
||||
"checksumHash": "校验和",
|
||||
"checksumExpectedHint": "预期哈希值(可选)",
|
||||
"checksumInvalidLength": "{algorithm} 校验和哈希长度无效:应为 {length} 个字符",
|
||||
"checksumInvalidHex": "校验和哈希无效:应为十六进制字符(0-9,a-f)",
|
||||
"deleteAfterExtract": "解压后删除压缩包",
|
||||
"extracting": "正在解压",
|
||||
"extractDone": "解压完成",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
-89
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -57,6 +57,17 @@ add_subdirectory("runner")
|
||||
# them to the application.
|
||||
include(flutter/generated_plugins.cmake)
|
||||
|
||||
# Temporary compatibility for legacy coroutine headers on MSVC 14.51.
|
||||
# Remove once both Windows plugins use standard coroutines.
|
||||
if(MSVC)
|
||||
foreach(plugin IN ITEMS flutter_inappwebview_windows_plugin permission_handler_windows_plugin)
|
||||
if(TARGET ${plugin})
|
||||
target_compile_definitions(${plugin} PRIVATE
|
||||
_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS)
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
|
||||
# === Installation ===
|
||||
# Support files are copied into place next to the executable, so that it can
|
||||
|
||||
Reference in new issue
Block a user