mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-11 10:54:08 +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)
|
// Wait for prefetch to finish (with timeout)
|
||||||
for i := 0; i < 1000 && !f.prefetchDone.Load(); i++ {
|
for i := 0; f.prefetchStopCh != nil && i < 1000 && !f.prefetchDone.Load(); i++ {
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,6 +675,22 @@ func (f *Fetcher) cleanupPrefetchFile() {
|
|||||||
f.resolveFallback.Store(false)
|
f.resolveFallback.Store(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prepareFullRedownload resets all fetcher connection and prefetch state
|
||||||
|
// after a checksum mismatch so the subsequent Start takes the fresh-start path.
|
||||||
|
func (f *Fetcher) prepareFullRedownload() {
|
||||||
|
f.connMu.Lock()
|
||||||
|
if f.meta.Res.Range {
|
||||||
|
f.connections = nil
|
||||||
|
f.resolveConn = nil
|
||||||
|
f.rangeReprobeEligible = false
|
||||||
|
f.rangeValidatorPinned = false
|
||||||
|
}
|
||||||
|
f.connMu.Unlock()
|
||||||
|
|
||||||
|
f.cleanupPrefetchFile()
|
||||||
|
f.resolveDataPos.Store(0)
|
||||||
|
}
|
||||||
|
|
||||||
// Options may be replaced between Resolve and Start when creating a resolved task.
|
// Options may be replaced between Resolve and Start when creating a resolved task.
|
||||||
func (f *Fetcher) initOptions() error {
|
func (f *Fetcher) initOptions() error {
|
||||||
opts := f.meta.Opts
|
opts := f.meta.Opts
|
||||||
@@ -692,6 +708,9 @@ func (f *Fetcher) initOptions() error {
|
|||||||
extra.Connections = 1
|
extra.Connections = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := extra.Checksum.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -746,8 +765,8 @@ func (f *Fetcher) doStart() error {
|
|||||||
|
|
||||||
// If retrying after error, reset connection states for retry
|
// If retrying after error, reset connection states for retry
|
||||||
if state == stateError {
|
if state == stateError {
|
||||||
forceFullReset := f.checksumFailed.Swap(false)
|
fullRedownload := f.checksumFailed.Swap(false)
|
||||||
if forceFullReset {
|
if fullRedownload {
|
||||||
// Checksum mismatch: the file content itself is wrong even
|
// Checksum mismatch: the file content itself is wrong even
|
||||||
// though every connection reported Completed. Truncate the
|
// though every connection reported Completed. Truncate the
|
||||||
// corrupt file so stale bytes cannot linger if the retry is
|
// 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) {
|
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)
|
return fmt.Errorf("failed to reset file after checksum mismatch: %w", err)
|
||||||
}
|
}
|
||||||
|
f.prepareFullRedownload()
|
||||||
}
|
}
|
||||||
f.connMu.Lock()
|
f.connMu.Lock()
|
||||||
for _, conn := range f.connections {
|
for _, conn := range f.connections {
|
||||||
@@ -762,10 +782,10 @@ func (f *Fetcher) doStart() error {
|
|||||||
// finish. On a checksum mismatch, reset EVERY connection —
|
// finish. On a checksum mismatch, reset EVERY connection —
|
||||||
// "Completed" only means the download loop finished, not
|
// "Completed" only means the download loop finished, not
|
||||||
// that the bytes were verified correct.
|
// that the bytes were verified correct.
|
||||||
shouldReset := forceFullReset || (!conn.Completed && conn.State != connCompleted)
|
shouldReset := fullRedownload || (!conn.Completed && conn.State != connCompleted)
|
||||||
if shouldReset {
|
if shouldReset {
|
||||||
if !f.hasSequentialPrefixLocked(conn) {
|
if fullRedownload || !f.hasSequentialPrefixLocked(conn) {
|
||||||
f.resetConnectionForRestart(conn, forceFullReset)
|
f.resetConnectionForRestart(conn)
|
||||||
}
|
}
|
||||||
conn.State = connNotStarted
|
conn.State = connNotStarted
|
||||||
conn.failed = false
|
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.
|
// it only when no safe validator exists or If-Range returns a full 200.
|
||||||
f.connMu.Lock()
|
f.connMu.Lock()
|
||||||
if !f.hasSequentialPrefixLocked(conn) {
|
if !f.hasSequentialPrefixLocked(conn) {
|
||||||
f.resetConnectionForRestart(conn, false)
|
f.resetConnectionForRestart(conn)
|
||||||
}
|
}
|
||||||
f.connMu.Unlock()
|
f.connMu.Unlock()
|
||||||
}
|
}
|
||||||
@@ -1245,7 +1265,7 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
|
|||||||
resumeProbe := f.canProbeSequentialResumeLocked(conn)
|
resumeProbe := f.canProbeSequentialResumeLocked(conn)
|
||||||
intentionalRestart := sequentialSizeUnknown
|
intentionalRestart := sequentialSizeUnknown
|
||||||
if sequentialSizeUnknown || (hasSequentialPrefix && !resumeProbe) {
|
if sequentialSizeUnknown || (hasSequentialPrefix && !resumeProbe) {
|
||||||
f.resetConnectionForRestart(conn, false)
|
f.resetConnectionForRestart(conn)
|
||||||
f.resolveDataPos.Store(0)
|
f.resolveDataPos.Store(0)
|
||||||
f.rangeValidatorPinned = false
|
f.rangeValidatorPinned = false
|
||||||
intentionalRestart = true
|
intentionalRestart = true
|
||||||
@@ -1690,7 +1710,7 @@ func (f *Fetcher) fallbackToSequentialDownload(conn *connection, ifRange string)
|
|||||||
f.rangeReprobeEligible = true
|
f.rangeReprobeEligible = true
|
||||||
f.rangeValidatorPinned = false
|
f.rangeValidatorPinned = false
|
||||||
f.ifRange = ifRange
|
f.ifRange = ifRange
|
||||||
f.resetConnectionForRestart(conn, false)
|
f.resetConnectionForRestart(conn)
|
||||||
f.resolveDataPos.Store(0)
|
f.resolveDataPos.Store(0)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1706,7 +1726,7 @@ func (f *Fetcher) restartSequentialDownload(conn *connection, ifRange string) {
|
|||||||
f.rangeReprobeEligible = true
|
f.rangeReprobeEligible = true
|
||||||
f.rangeValidatorPinned = false
|
f.rangeValidatorPinned = false
|
||||||
f.ifRange = ifRange
|
f.ifRange = ifRange
|
||||||
f.resetConnectionForRestart(conn, false)
|
f.resetConnectionForRestart(conn)
|
||||||
f.resolveDataPos.Store(0)
|
f.resolveDataPos.Store(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2089,19 +2109,10 @@ func (f *Fetcher) helpOtherConnection(helper *connection) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) resetConnectionForRestart(conn *connection, force bool) {
|
func (f *Fetcher) resetConnectionForRestart(conn *connection) {
|
||||||
if f.meta.Res.Range && !force {
|
if f.meta.Res.Range {
|
||||||
return
|
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,
|
// Without range support a new request always starts from byte 0,
|
||||||
// so pause/retry must restart instead of continuing from the old offset.
|
// so pause/retry must restart instead of continuing from the old offset.
|
||||||
@@ -2171,7 +2182,7 @@ func (f *Fetcher) resumeConnections() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !f.hasSequentialPrefixLocked(conn) {
|
if !f.hasSequentialPrefixLocked(conn) {
|
||||||
f.resetConnectionForRestart(conn, false)
|
f.resetConnectionForRestart(conn)
|
||||||
}
|
}
|
||||||
// Reset the connection state for resume
|
// Reset the connection state for resume
|
||||||
conn.ctx, conn.cancel = context.WithCancel(f.ctx)
|
conn.ctx, conn.cancel = context.WithCancel(f.ctx)
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
gohttp "net/http"
|
gohttp "net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,29 +18,61 @@ import (
|
|||||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
"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) {
|
func TestFetcher_ChecksumMismatch_Retry(t *testing.T) {
|
||||||
corruptContent := []byte("Corrupt Initial Download Data!")
|
corruptContent := []byte("Corrupt Initial Download Data 33B")
|
||||||
correctContent := []byte("Correct Retried Download Data!")
|
correctContent := []byte("Correct Retried Download Data 33B")
|
||||||
|
|
||||||
correctSha256Hasher := sha256.New()
|
correctSha256Hasher := sha256.New()
|
||||||
correctSha256Hasher.Write(correctContent)
|
correctSha256Hasher.Write(correctContent)
|
||||||
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
|
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) {
|
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||||
count := reqCount.Add(1)
|
|
||||||
var served []byte
|
var served []byte
|
||||||
if count == 1 {
|
if phase.Load() == 0 {
|
||||||
// First request: serve corrupted content
|
|
||||||
served = corruptContent
|
served = corruptContent
|
||||||
} else {
|
} else {
|
||||||
// Second / subsequent request: serve correct content
|
|
||||||
served = correctContent
|
served = correctContent
|
||||||
}
|
}
|
||||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(served)))
|
serveRangeContent(w, r, served)
|
||||||
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
|
||||||
w.WriteHeader(gohttp.StatusOK)
|
|
||||||
_, _ = w.Write(served)
|
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
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 {
|
if err := f.Resolve(&base.Request{URL: server.URL + "/retry_test.txt"}, opts); err != nil {
|
||||||
t.Fatalf("Resolve error: %v", err)
|
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 {
|
if err := f.Start(); err != nil {
|
||||||
t.Fatalf("Start error: %v", err)
|
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())
|
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
|
// 2. Retry download -> should trigger forced full reset, truncate file, and re-download correctContent
|
||||||
if err := f.Start(); err != nil {
|
if err := f.Start(); err != nil {
|
||||||
t.Fatalf("Start error on retry: %v", err)
|
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)
|
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"
|
gohttp "net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
|
||||||
"github.com/GopeedLab/gopeed/internal/test"
|
"github.com/GopeedLab/gopeed/internal/test"
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
"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 {
|
err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts)
|
||||||
t.Fatalf("Resolve error: %v", err)
|
|
||||||
}
|
|
||||||
if err := f.Start(); err != nil {
|
|
||||||
t.Fatalf("Start error: %v", err)
|
|
||||||
}
|
|
||||||
err := f.Wait()
|
|
||||||
if err == nil {
|
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") {
|
if !strings.Contains(err.Error(), "unsupported checksum algorithm") {
|
||||||
t.Fatalf("expected 'unsupported checksum algorithm' in error, got %v", err)
|
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)
|
t.Fatalf("Wait error: %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
func TestResetConnectionForRestart_ForceOnRangeDownload(t *testing.T) {
|
t.Run("Invalid Checksum In Resolve Makes Zero Requests", func(t *testing.T) {
|
||||||
f := buildFetcher()
|
var reqReceived atomic.Int32
|
||||||
f.meta = &fetcher.FetcherMeta{
|
customServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||||
Res: &base.Resource{
|
reqReceived.Add(1)
|
||||||
Range: true,
|
w.WriteHeader(gohttp.StatusOK)
|
||||||
},
|
_, _ = w.Write([]byte("some data"))
|
||||||
}
|
}))
|
||||||
conn := &connection{
|
defer customServer.Close()
|
||||||
Chunk: newChunk(0, 1024),
|
|
||||||
Downloaded: 512,
|
|
||||||
Completed: true,
|
|
||||||
}
|
|
||||||
conn.Chunk.Downloaded = 512
|
|
||||||
|
|
||||||
// Non-forced reset on range-supported download does not reset Chunk.Downloaded
|
f := buildFetcher()
|
||||||
f.resetConnectionForRestart(conn, false)
|
defer f.Close()
|
||||||
if conn.Chunk.Downloaded != 512 {
|
|
||||||
t.Fatalf("expected Chunk.Downloaded to remain 512 when force is false, got %d", conn.Chunk.Downloaded)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forced reset on range-supported download resets Chunk.Downloaded to 0
|
opts := &base.Options{
|
||||||
f.resetConnectionForRestart(conn, true)
|
Path: t.TempDir(),
|
||||||
if conn.Chunk.Downloaded != 0 {
|
Name: "test_invalid_checksum.txt",
|
||||||
t.Fatalf("expected Chunk.Downloaded to be reset to 0 when force is true, got %d", conn.Chunk.Downloaded)
|
Extra: &http.OptsExtra{
|
||||||
}
|
Connections: 1,
|
||||||
if conn.Completed {
|
Checksum: &http.ChecksumOption{
|
||||||
t.Fatal("expected Completed to be false after reset")
|
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
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
type ReqExtra struct {
|
type ReqExtra struct {
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
Header map[string]string `json:"header"`
|
Header map[string]string `json:"header"`
|
||||||
@@ -13,6 +19,65 @@ type ChecksumOption struct {
|
|||||||
Expected string `json:"expected"` // hex-encoded expected hash
|
Expected string `json:"expected"` // hex-encoded expected hash
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsEmpty returns true when chk is nil, or when both Algorithm and Expected
|
||||||
|
// are blank after trimming whitespace.
|
||||||
|
func (chk *ChecksumOption) IsEmpty() bool {
|
||||||
|
if chk == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(chk.Algorithm) == "" && strings.TrimSpace(chk.Expected) == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizedAlgorithm returns the canonical lower-cased algorithm ("md5", "sha1", "sha256")
|
||||||
|
// and true if recognized, or ("", false) if unsupported.
|
||||||
|
func (chk *ChecksumOption) NormalizedAlgorithm() (string, bool) {
|
||||||
|
if chk == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(chk.Algorithm)) {
|
||||||
|
case "md5":
|
||||||
|
return "md5", true
|
||||||
|
case "sha1", "sha-1":
|
||||||
|
return "sha1", true
|
||||||
|
case "sha256", "sha-256":
|
||||||
|
return "sha256", true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks whether the checksum option is valid.
|
||||||
|
// Empty options return nil (no verification).
|
||||||
|
func (chk *ChecksumOption) Validate() error {
|
||||||
|
if chk.IsEmpty() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
algo, ok := chk.NormalizedAlgorithm()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unsupported checksum algorithm: %s", chk.Algorithm)
|
||||||
|
}
|
||||||
|
expected := strings.TrimSpace(chk.Expected)
|
||||||
|
if expected == "" {
|
||||||
|
return fmt.Errorf("checksum expected hash is required when algorithm is set")
|
||||||
|
}
|
||||||
|
var expectedLen int
|
||||||
|
switch algo {
|
||||||
|
case "md5":
|
||||||
|
expectedLen = 32
|
||||||
|
case "sha1":
|
||||||
|
expectedLen = 40
|
||||||
|
case "sha256":
|
||||||
|
expectedLen = 64
|
||||||
|
}
|
||||||
|
if len(expected) != expectedLen {
|
||||||
|
return fmt.Errorf("invalid checksum hash length for %s: expected %d characters, got %d", algo, expectedLen, len(expected))
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(expected); err != nil {
|
||||||
|
return fmt.Errorf("invalid checksum hash for %s: expected valid hexadecimal string", algo)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type OptsExtra struct {
|
type OptsExtra struct {
|
||||||
Connections int `json:"connections"`
|
Connections int `json:"connections"`
|
||||||
// AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file
|
// AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file
|
||||||
|
|||||||
@@ -1,15 +1,162 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestOptsExtra_Checksum(t *testing.T) {
|
func TestOptsExtra_Checksum(t *testing.T) {
|
||||||
extra := &OptsExtra{
|
extra := &OptsExtra{
|
||||||
Checksum: &ChecksumOption{
|
Checksum: &ChecksumOption{
|
||||||
Algorithm: "sha256",
|
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)
|
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:
|
Use the following desktop reference metrics unless a specific screen requires a documented exception:
|
||||||
|
|
||||||
- Reference window: `1024 x 768`
|
- 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`
|
- Secondary filter sidebar width: `256px`
|
||||||
- Main content header height: `80px`
|
- Main content header height: `80px`
|
||||||
- Window drag header height on Windows: `30px`
|
- Window drag header height on Windows: `30px`
|
||||||
|
|||||||
@@ -85,12 +85,6 @@ Appearance synchronization uses `appearance.changed` and currently contains:
|
|||||||
- Theme color
|
- Theme color
|
||||||
- Locale
|
- 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.
|
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
|
## 6. Storage
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ class GopeedSiteApi {
|
|||||||
static final instance = GopeedSiteApi._();
|
static final instance = GopeedSiteApi._();
|
||||||
|
|
||||||
static const _host = 'gopeed.com';
|
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 {
|
Future<List<dynamic>> getReleases({int perPage = 10}) async {
|
||||||
final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()});
|
final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()});
|
||||||
@@ -28,50 +26,18 @@ class GopeedSiteApi {
|
|||||||
StoreExtensionSort sort = StoreExtensionSort.stars,
|
StoreExtensionSort sort = StoreExtensionSort.stars,
|
||||||
StoreSortOrder order = StoreSortOrder.desc,
|
StoreSortOrder order = StoreSortOrder.desc,
|
||||||
String? query,
|
String? query,
|
||||||
bool forceRefresh = false,
|
|
||||||
}) async {
|
}) async {
|
||||||
final params = {
|
final json = await _getJson(
|
||||||
'page': page.toString(),
|
'/api/extensions',
|
||||||
'limit': limit.clamp(1, 100).toString(),
|
queryParameters: {
|
||||||
'sort': sort.name,
|
'page': page.toString(),
|
||||||
'order': order.name,
|
'limit': limit.clamp(1, 100).toString(),
|
||||||
'view': 'summary',
|
'sort': sort.name,
|
||||||
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
|
'order': order.name,
|
||||||
};
|
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},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return StoreExtension.fromJson(json as Map<String, dynamic>);
|
return StoreExtensionPage.fromJson(json as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> reportExtensionInstall(String id) async {
|
Future<void> reportExtensionInstall(String id) async {
|
||||||
@@ -83,11 +49,8 @@ class GopeedSiteApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) {
|
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) async {
|
||||||
return _getJsonUri(Uri.https(_host, path, queryParameters));
|
final uri = Uri.https(_host, path, queryParameters);
|
||||||
}
|
|
||||||
|
|
||||||
Future<dynamic> _getJsonUri(Uri uri) async {
|
|
||||||
final Response<String> response = await proxyRequest(uri.toString());
|
final Response<String> response = await proxyRequest(uri.toString());
|
||||||
if (response.data == null || response.data!.isEmpty) {
|
if (response.data == null || response.data!.isEmpty) {
|
||||||
throw Exception('Empty response from $uri');
|
throw Exception('Empty response from $uri');
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ class StoreExtension {
|
|||||||
final List<String> topics;
|
final List<String> topics;
|
||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
final DateTime? updatedAt;
|
final DateTime? updatedAt;
|
||||||
final bool hasDetails;
|
|
||||||
|
|
||||||
StoreExtension({
|
StoreExtension({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -85,7 +84,6 @@ class StoreExtension {
|
|||||||
required this.topics,
|
required this.topics,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
this.updatedAt,
|
this.updatedAt,
|
||||||
this.hasDetails = true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
factory StoreExtension.fromJson(Map<String, dynamic> json) {
|
factory StoreExtension.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -108,7 +106,6 @@ class StoreExtension {
|
|||||||
topics: _parseTopics(json['topics']),
|
topics: _parseTopics(json['topics']),
|
||||||
createdAt: _parseDate(json['createdAt']),
|
createdAt: _parseDate(json['createdAt']),
|
||||||
updatedAt: _parseDate(json['updatedAt']),
|
updatedAt: _parseDate(json['updatedAt']),
|
||||||
hasDetails: json.containsKey('readme'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import 'dart:collection';
|
|||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../api/gopeed_site_api.dart';
|
||||||
import '../../../api/model/extension.dart';
|
import '../../../api/model/extension.dart';
|
||||||
import '../../../api/model/install_extension.dart';
|
import '../../../api/model/install_extension.dart';
|
||||||
import '../../../api/model/store_extension.dart';
|
import '../../../api/model/store_extension.dart';
|
||||||
import '../../../api/model/switch_extension.dart';
|
import '../../../api/model/switch_extension.dart';
|
||||||
import '../../../api/model/update_extension_settings.dart';
|
import '../../../api/model/update_extension_settings.dart';
|
||||||
import '../../../core/capabilities/app_capabilities.dart';
|
import '../../../core/capabilities/app_capabilities.dart';
|
||||||
import 'store_extension_details_provider.dart';
|
|
||||||
|
|
||||||
final extensionsControllerProvider = AsyncNotifierProvider<ExtensionsController, ExtensionsState>(
|
final extensionsControllerProvider = AsyncNotifierProvider<ExtensionsController, ExtensionsState>(
|
||||||
ExtensionsController.new,
|
ExtensionsController.new,
|
||||||
@@ -121,81 +121,63 @@ class ExtensionsState {
|
|||||||
class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
||||||
static const manualInstallBusyKey = '__manual_install__';
|
static const manualInstallBusyKey = '__manual_install__';
|
||||||
int _devModeCount = 0;
|
int _devModeCount = 0;
|
||||||
int _installedRevision = 0;
|
|
||||||
int _storeRevision = 0;
|
|
||||||
Future<void>? _updateCheck;
|
|
||||||
bool _checkUpdatesAgain = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ExtensionsState> build() => _loadInitialData();
|
Future<ExtensionsState> build() async {
|
||||||
|
var next = const ExtensionsState(loadingInstalled: true, loadingStore: true);
|
||||||
Future<ExtensionsState> _loadInitialData({bool forceRefresh = false}) async {
|
|
||||||
final next = _current.copyWith(loadingInstalled: true, loadingStore: true);
|
|
||||||
state = AsyncValue.data(next);
|
state = AsyncValue.data(next);
|
||||||
await Future.wait([
|
final installed = await _loadInstalled(refreshUpdates: true, current: next);
|
||||||
_loadInstalled(refreshUpdates: true, current: next),
|
next = state.value ?? installed;
|
||||||
_refreshStore(current: _current, forceRefresh: forceRefresh),
|
await _refreshStore(current: next);
|
||||||
]);
|
return state.value ?? next;
|
||||||
return ref.mounted ? _current : next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadInitialData() async {
|
Future<void> loadInitialData() async {
|
||||||
await _loadInitialData(forceRefresh: true);
|
state = const AsyncValue.loading();
|
||||||
|
state = AsyncValue.data(await build());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadInstalled({bool refreshUpdates = false}) async {
|
Future<void> loadInstalled({bool refreshUpdates = false}) async {
|
||||||
if (!ref.mounted) return;
|
|
||||||
await _loadInstalled(refreshUpdates: refreshUpdates, current: _current.copyWith(loadingInstalled: true));
|
await _loadInstalled(refreshUpdates: refreshUpdates, current: _current.copyWith(loadingInstalled: true));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refreshStore() async {
|
Future<void> refreshStore() async {
|
||||||
await _refreshStore(current: _current.copyWith(loadingStore: true), forceRefresh: true);
|
await _refreshStore(current: _current.copyWith(loadingStore: true));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadMoreStore() async {
|
Future<void> loadMoreStore() async {
|
||||||
final current = _current;
|
final current = _current;
|
||||||
final pagination = current.storePagination;
|
final pagination = current.storePagination;
|
||||||
if (pagination == null || !pagination.hasNext || current.loadingMoreStore) return;
|
if (pagination == null || !pagination.hasNext || current.loadingMoreStore) return;
|
||||||
final revision = _storeRevision;
|
|
||||||
state = AsyncValue.data(current.copyWith(loadingMoreStore: true));
|
state = AsyncValue.data(current.copyWith(loadingMoreStore: true));
|
||||||
try {
|
try {
|
||||||
final page = await ref
|
final page = await GopeedSiteApi.instance.getExtensions(
|
||||||
.read(gopeedSiteApiProvider)
|
page: pagination.page + 1,
|
||||||
.getExtensions(
|
limit: pagination.limit,
|
||||||
page: pagination.page + 1,
|
sort: current.storeSort,
|
||||||
limit: pagination.limit,
|
query: current.storeQuery,
|
||||||
sort: current.storeSort,
|
);
|
||||||
query: current.storeQuery,
|
|
||||||
);
|
|
||||||
if (!ref.mounted || revision != _storeRevision) return;
|
|
||||||
state = AsyncValue.data(
|
state = AsyncValue.data(
|
||||||
_current.copyWith(
|
_current.copyWith(
|
||||||
// Cached pages can briefly overlap when stars/install counts change.
|
storeExtensions: [..._current.storeExtensions, ...page.data],
|
||||||
storeExtensions: {
|
|
||||||
for (final extension in _current.storeExtensions) extension.id: extension,
|
|
||||||
for (final extension in page.data) extension.id: extension,
|
|
||||||
}.values.toList(),
|
|
||||||
storePagination: page.pagination,
|
storePagination: page.pagination,
|
||||||
loadingMoreStore: false,
|
loadingMoreStore: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (!ref.mounted || revision != _storeRevision) return;
|
|
||||||
state = AsyncValue.data(_current.copyWith(loadingMoreStore: false));
|
state = AsyncValue.data(_current.copyWith(loadingMoreStore: false));
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> searchStore(String query) async {
|
Future<void> searchStore(String query) async {
|
||||||
if (query.trim() == _current.storeQuery) return;
|
|
||||||
state = AsyncValue.data(_current.copyWith(storeQuery: query.trim()));
|
state = AsyncValue.data(_current.copyWith(storeQuery: query.trim()));
|
||||||
await _refreshStore(current: _current);
|
await refreshStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> changeSort(StoreExtensionSort sort) async {
|
Future<void> changeSort(StoreExtensionSort sort) async {
|
||||||
if (sort == _current.storeSort) return;
|
|
||||||
state = AsyncValue.data(_current.copyWith(storeSort: sort));
|
state = AsyncValue.data(_current.copyWith(storeSort: sort));
|
||||||
await _refreshStore(current: _current);
|
await refreshStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
void changeFilter(ExtensionListFilter filter) {
|
void changeFilter(ExtensionListFilter filter) {
|
||||||
@@ -275,43 +257,23 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> checkUpdate() {
|
Future<void> checkUpdate() async {
|
||||||
if (!ref.mounted) return Future.value();
|
final flags = <String, String>{};
|
||||||
_checkUpdatesAgain = true;
|
for (final ext in _current.installedExtensions) {
|
||||||
// Installation/refresh can request a fresh scan while one is running.
|
try {
|
||||||
// Coalesce those requests instead of launching overlapping Git operations.
|
final resp = await ref.read(gopeedServiceProvider).upgradeCheckExtension(ext.identity);
|
||||||
return _updateCheck ??= _runUpdateChecks().whenComplete(() => _updateCheck = null);
|
if (resp.newVersion.isNotEmpty) {
|
||||||
}
|
flags[ext.identity] = resp.newVersion;
|
||||||
|
|
||||||
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.
|
|
||||||
}
|
}
|
||||||
}
|
} catch (_) {}
|
||||||
if (ref.mounted && revision == _installedRevision) {
|
|
||||||
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
|
||||||
}
|
}
|
||||||
|
|
||||||
void tryOpenDevMode() {
|
void tryOpenDevMode() {
|
||||||
if (_devModeCount == 0) {
|
if (_devModeCount == 0) {
|
||||||
Future.delayed(const Duration(seconds: 2), () {
|
Future.delayed(const Duration(seconds: 2), () {
|
||||||
if (!ref.mounted || _current.devMode) return;
|
if (_current.devMode) return;
|
||||||
_devModeCount = 0;
|
_devModeCount = 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -324,40 +286,26 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|||||||
ExtensionsState get _current => state.value ?? const ExtensionsState();
|
ExtensionsState get _current => state.value ?? const ExtensionsState();
|
||||||
|
|
||||||
Future<ExtensionsState> _loadInstalled({required bool refreshUpdates, required ExtensionsState current}) async {
|
Future<ExtensionsState> _loadInstalled({required bool refreshUpdates, required ExtensionsState current}) async {
|
||||||
final revision = ++_installedRevision;
|
|
||||||
state = AsyncValue.data(current.copyWith(loadingInstalled: true));
|
state = AsyncValue.data(current.copyWith(loadingInstalled: true));
|
||||||
final installed = await ref.read(gopeedServiceProvider).getExtensions();
|
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));
|
state = AsyncValue.data(_current.copyWith(installedExtensions: installed, loadingInstalled: false));
|
||||||
if (refreshUpdates) {
|
if (refreshUpdates) {
|
||||||
unawaited(checkUpdate());
|
await checkUpdate();
|
||||||
}
|
}
|
||||||
return _current;
|
return _current;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshStore({required ExtensionsState current, bool forceRefresh = false}) async {
|
Future<void> _refreshStore({required ExtensionsState current}) async {
|
||||||
final revision = ++_storeRevision;
|
state = AsyncValue.data(current.copyWith(loadingStore: true));
|
||||||
state = AsyncValue.data(current.copyWith(loadingStore: true, loadingMoreStore: false));
|
final page = await GopeedSiteApi.instance.getExtensions(
|
||||||
try {
|
page: 1,
|
||||||
final page = await ref
|
limit: 20,
|
||||||
.read(gopeedSiteApiProvider)
|
sort: _current.storeSort,
|
||||||
.getExtensions(
|
query: _current.storeQuery,
|
||||||
page: 1,
|
);
|
||||||
limit: 20,
|
state = AsyncValue.data(
|
||||||
sort: current.storeSort,
|
_current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false),
|
||||||
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> _runBusy(String id, Future<void> Function() action) async {
|
Future<void> _runBusy(String id, Future<void> Function() action) async {
|
||||||
@@ -366,15 +314,12 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
} finally {
|
} finally {
|
||||||
if (ref.mounted) {
|
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
|
||||||
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
|
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
|
||||||
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _bumpStoreInstallCount(String id) {
|
void _bumpStoreInstallCount(String id) {
|
||||||
if (!ref.mounted) return;
|
|
||||||
final current = _current;
|
final current = _current;
|
||||||
final index = current.storeExtensions.indexWhere((e) => e.id == id);
|
final index = current.storeExtensions.indexWhere((e) => e.id == id);
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
@@ -399,17 +344,14 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|||||||
topics: ext.topics,
|
topics: ext.topics,
|
||||||
createdAt: ext.createdAt,
|
createdAt: ext.createdAt,
|
||||||
updatedAt: ext.updatedAt,
|
updatedAt: ext.updatedAt,
|
||||||
hasDetails: ext.hasDetails,
|
|
||||||
);
|
);
|
||||||
state = AsyncValue.data(current.copyWith(storeExtensions: updated));
|
state = AsyncValue.data(current.copyWith(storeExtensions: updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reportInstallSafe(String id) {
|
void _reportInstallSafe(String id) {
|
||||||
if (!ref.mounted) return;
|
|
||||||
final siteApi = ref.read(gopeedSiteApiProvider);
|
|
||||||
unawaited(() async {
|
unawaited(() async {
|
||||||
try {
|
try {
|
||||||
await siteApi.reportExtensionInstall(id);
|
await GopeedSiteApi.instance.reportExtensionInstall(id);
|
||||||
} catch (_) {}
|
} 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_dialog.dart';
|
||||||
import '../widgets/extension_update_status.dart';
|
import '../widgets/extension_update_status.dart';
|
||||||
|
|
||||||
// Keep three columns in the 1024px reference window with the wider primary rail.
|
const _extensionCardMinWidth = 290.0;
|
||||||
const _extensionCardMinWidth = 280.0;
|
|
||||||
const _extensionGridSpacing = 10.0;
|
const _extensionGridSpacing = 10.0;
|
||||||
|
|
||||||
int _extensionGridColumnCount(double width) {
|
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 '../../../../shared/widgets/detail/app_detail_surface.dart';
|
||||||
import '../../../../util/util.dart';
|
import '../../../../util/util.dart';
|
||||||
import '../../application/extensions_controller.dart';
|
import '../../application/extensions_controller.dart';
|
||||||
import '../../application/store_extension_details_provider.dart';
|
|
||||||
import 'extension_icon.dart';
|
import 'extension_icon.dart';
|
||||||
import 'extension_update_dialog.dart';
|
import 'extension_update_dialog.dart';
|
||||||
import 'extension_update_status.dart';
|
import 'extension_update_status.dart';
|
||||||
@@ -64,13 +63,7 @@ class ExtensionDetailView extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final palette = AppPalette.of(context);
|
final palette = AppPalette.of(context);
|
||||||
final state = ref.watch(extensionsControllerProvider).value;
|
final state = ref.watch(extensionsControllerProvider).value;
|
||||||
final summary = state?.findItem(item.id) ?? item;
|
final current = 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 installed = current.installed;
|
final installed = current.installed;
|
||||||
final store = current.store;
|
final store = current.store;
|
||||||
final busy = state?.busyExtensionIds.contains(current.id) ?? false;
|
final busy = state?.busyExtensionIds.contains(current.id) ?? false;
|
||||||
@@ -123,7 +116,7 @@ class ExtensionDetailView extends ConsumerWidget {
|
|||||||
'v${current.version}',
|
'v${current.version}',
|
||||||
style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600),
|
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)
|
SizedBox(height: mobile ? 24 : 28),
|
||||||
const Padding(
|
_ExtensionReadme(
|
||||||
key: ValueKey('extension-details-loading'),
|
// Snapshot the version so upgrades reload the installed README.
|
||||||
padding: EdgeInsets.only(top: 24),
|
key: ValueKey((current.id, installed?.version, installed?.devMode, installed?.devPath, store?.readme)),
|
||||||
child: Center(child: shad.CircularProgressIndicator()),
|
item: current,
|
||||||
),
|
mobile: mobile,
|
||||||
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)],
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -204,7 +188,7 @@ class ExtensionDetailView extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ExtensionReadme extends StatefulWidget {
|
class _ExtensionReadme extends StatefulWidget {
|
||||||
const _ExtensionReadme({required this.item, required this.mobile});
|
const _ExtensionReadme({super.key, required this.item, required this.mobile});
|
||||||
|
|
||||||
final ExtensionListItem item;
|
final ExtensionListItem item;
|
||||||
final bool mobile;
|
final bool mobile;
|
||||||
@@ -214,7 +198,7 @@ class _ExtensionReadme extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ExtensionReadmeState extends State<_ExtensionReadme> {
|
class _ExtensionReadmeState extends State<_ExtensionReadme> {
|
||||||
late Future<_ReadmeInfo> _readme;
|
late final _ReadmeInfo _readme;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -222,81 +206,48 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> {
|
|||||||
_readme = _loadReadme(widget.item);
|
_readme = _loadReadme(widget.item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(covariant _ExtensionReadme oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
final previous = oldWidget.item.installed;
|
|
||||||
final current = widget.item.installed;
|
|
||||||
if (previous?.identity != current?.identity ||
|
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final palette = AppPalette.of(context);
|
final palette = AppPalette.of(context);
|
||||||
return FutureBuilder<_ReadmeInfo>(
|
final info = _readme;
|
||||||
future: _readme,
|
if (info.content.trim().isEmpty) return const SizedBox.shrink();
|
||||||
builder: (context, snapshot) {
|
return Padding(
|
||||||
final info = snapshot.data;
|
key: const ValueKey('extension-details-readme'),
|
||||||
if (info == null) {
|
padding: EdgeInsets.only(top: widget.mobile ? 32 : 36),
|
||||||
return const SizedBox.shrink();
|
child: Column(
|
||||||
}
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
if (info.content.trim().isEmpty) {
|
children: [
|
||||||
return const SizedBox.shrink();
|
Text(
|
||||||
}
|
'README',
|
||||||
return Padding(
|
style: TextStyle(color: palette.textMuted, fontSize: 10.5, fontWeight: FontWeight.w800, letterSpacing: 1.2),
|
||||||
key: const ValueKey('extension-details-readme'),
|
|
||||||
padding: EdgeInsets.only(top: widget.mobile ? 32 : 36),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'README',
|
|
||||||
style: TextStyle(
|
|
||||||
color: palette.textMuted,
|
|
||||||
fontSize: 10.5,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
letterSpacing: 1.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
MarkdownBody(
|
|
||||||
data: info.content,
|
|
||||||
selectable: true,
|
|
||||||
styleSheet: _markdownStyle(palette, mobile: widget.mobile),
|
|
||||||
onTapLink: (_, href, _) {
|
|
||||||
final resolved = _resolveReadmeUrl(widget.item, href, info: info, forImage: false);
|
|
||||||
if (resolved != null) unawaited(_openUrl(resolved));
|
|
||||||
},
|
|
||||||
imageBuilder: (uri, title, alt) {
|
|
||||||
final resolved = _resolveReadmeUrl(widget.item, uri.toString(), info: info, forImage: true);
|
|
||||||
if (resolved == null) return const SizedBox.shrink();
|
|
||||||
final image = resolved.startsWith('file:')
|
|
||||||
? Image.file(
|
|
||||||
File(Uri.parse(resolved).toFilePath()),
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
|
||||||
)
|
|
||||||
: Image.network(
|
|
||||||
resolved,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
|
||||||
);
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
||||||
child: ClipRRect(borderRadius: BorderRadius.circular(6), child: image),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
const SizedBox(height: 16),
|
||||||
},
|
MarkdownBody(
|
||||||
|
data: info.content,
|
||||||
|
selectable: true,
|
||||||
|
styleSheet: _markdownStyle(palette, mobile: widget.mobile),
|
||||||
|
onTapLink: (_, href, _) {
|
||||||
|
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 installed = item.installed;
|
||||||
final remote = item.store?.readme ?? '';
|
final remote = item.store?.readme ?? '';
|
||||||
if (installed == null || Util.isWeb()) return _ReadmeInfo(content: remote);
|
if (installed == null || Util.isWeb()) return _ReadmeInfo(content: remote);
|
||||||
|
|||||||
@@ -2128,7 +2128,7 @@ class _BrowserExtensionLinks extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Icons.open_in_new, size: 15),
|
const Icon(Icons.open_in_new, size: 15),
|
||||||
const SizedBox(width: AppDesignTokens.space8),
|
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;
|
Map<String, String>? _initialLabels;
|
||||||
RequestProxyMode _proxyMode = RequestProxyMode.follow;
|
RequestProxyMode _proxyMode = RequestProxyMode.follow;
|
||||||
String _proxyScheme = 'http';
|
String _proxyScheme = 'http';
|
||||||
String _checksumAlgorithm = 'sha256';
|
String _checksumAlgorithm = 'md5';
|
||||||
bool _skipVerifyCert = false;
|
bool _skipVerifyCert = false;
|
||||||
bool? _autoTorrent;
|
bool? _autoTorrent;
|
||||||
bool? _deleteTorrentAfterDownload;
|
bool? _deleteTorrentAfterDownload;
|
||||||
@@ -187,6 +187,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) {
|
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) {
|
||||||
_fileDataUri = '';
|
_fileDataUri = '';
|
||||||
}
|
}
|
||||||
|
if (_inputUrls().length > 1) {
|
||||||
|
_checksumHashController.clear();
|
||||||
|
_checksumAlgorithm = 'md5';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_recognizeMagnetUri(urlText.trim());
|
_recognizeMagnetUri(urlText.trim());
|
||||||
}
|
}
|
||||||
@@ -196,6 +200,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
final req = task.req;
|
final req = task.req;
|
||||||
final opts = task.opts;
|
final opts = task.opts;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_checksumAlgorithm = 'md5';
|
||||||
|
_checksumHashController.clear();
|
||||||
if (req?.url.isNotEmpty == true) {
|
if (req?.url.isNotEmpty == true) {
|
||||||
_fileDataUri = '';
|
_fileDataUri = '';
|
||||||
_renameController.clear();
|
_renameController.clear();
|
||||||
@@ -677,32 +683,34 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 14),
|
if (_inputUrls().length <= 1) ...[
|
||||||
AppFormPair(
|
const SizedBox(height: 14),
|
||||||
direction: stacked ? Axis.vertical : Axis.horizontal,
|
AppFormPair(
|
||||||
firstLabel: 'Checksum Algorithm',
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
secondLabel: 'Checksum Hash',
|
firstLabel: context.l10n.checksumAlgorithm,
|
||||||
firstFlex: 382,
|
secondLabel: context.l10n.checksumHash,
|
||||||
secondFlex: 618,
|
firstFlex: 382,
|
||||||
first: AppChoiceSegmentedControl<String>(
|
secondFlex: 618,
|
||||||
key: const ValueKey('create-task-checksum-algorithm'),
|
first: AppChoiceSegmentedControl<String>(
|
||||||
showIcons: false,
|
key: const ValueKey('create-task-checksum-algorithm'),
|
||||||
value: _checksumAlgorithm,
|
showIcons: false,
|
||||||
buttonKeyPrefix: 'create-task-checksum-algorithm',
|
value: _checksumAlgorithm,
|
||||||
alignment: WrapAlignment.start,
|
buttonKeyPrefix: 'create-task-checksum-algorithm',
|
||||||
options: const [
|
alignment: WrapAlignment.start,
|
||||||
AppChoiceOption(value: 'md5', label: 'MD5', icon: Icons.tag),
|
options: const [
|
||||||
AppChoiceOption(value: 'sha1', label: 'SHA-1', icon: Icons.tag),
|
AppChoiceOption(value: 'md5', label: 'MD5', icon: Icons.tag),
|
||||||
AppChoiceOption(value: 'sha256', label: 'SHA-256', 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),
|
],
|
||||||
|
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 ...[
|
] else ...[
|
||||||
AppFormRow(
|
AppFormRow(
|
||||||
direction: stacked ? Axis.vertical : Axis.horizontal,
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
@@ -985,6 +993,25 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _validateAdvancedOptions() {
|
bool _validateAdvancedOptions() {
|
||||||
|
if (_protocolTab == 0 && _inputUrls().length <= 1) {
|
||||||
|
final checksumExpected = _checksumHashController.text.trim();
|
||||||
|
if (checksumExpected.isNotEmpty) {
|
||||||
|
final expectedLen = switch (_checksumAlgorithm.toLowerCase()) {
|
||||||
|
'md5' => 32,
|
||||||
|
'sha1' || 'sha-1' => 40,
|
||||||
|
'sha256' || 'sha-256' => 64,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
if (expectedLen > 0 && checksumExpected.length != expectedLen) {
|
||||||
|
_showToast(context.l10n.checksumInvalidLength(_checksumAlgorithm.toUpperCase(), expectedLen.toString()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!RegExp(r'^[0-9a-fA-F]+$').hasMatch(checksumExpected)) {
|
||||||
|
_showToast(context.l10n.checksumInvalidHex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (_proxyMode != RequestProxyMode.custom) {
|
if (_proxyMode != RequestProxyMode.custom) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1057,10 +1084,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
return text.length == 40 && RegExp(r'^[0-9a-fA-F]+$').hasMatch(text);
|
return text.length == 40 && RegExp(r'^[0-9a-fA-F]+$').hasMatch(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
api_options.Options _buildOptions({String? name, List<int> selectFiles = const []}) {
|
api_options.Options _buildOptions({String? name, List<int> selectFiles = const [], bool omitChecksum = false}) {
|
||||||
final connections = int.tryParse(_connectionsController.text.trim()) ?? 0;
|
final connections = int.tryParse(_connectionsController.text.trim()) ?? 0;
|
||||||
final checksumExpected = _checksumHashController.text.trim();
|
final checksumExpected = _checksumHashController.text.trim();
|
||||||
final checksum = checksumExpected.isNotEmpty
|
final checksum = (!omitChecksum && _inputUrls().length <= 1 && checksumExpected.isNotEmpty)
|
||||||
? api_options.ChecksumOption(
|
? api_options.ChecksumOption(
|
||||||
algorithm: _checksumAlgorithm,
|
algorithm: _checksumAlgorithm,
|
||||||
expected: checksumExpected,
|
expected: checksumExpected,
|
||||||
@@ -1093,13 +1120,14 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
Future<void> _submitResolved(Request request, ResolveResult result, List<int> selectedIndexes) async {
|
Future<void> _submitResolved(Request request, ResolveResult result, List<int> selectedIndexes) async {
|
||||||
final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList())
|
final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList())
|
||||||
..sort();
|
..sort();
|
||||||
|
final isMultiFile = selected.length > 1;
|
||||||
if (result.id.isNotEmpty) {
|
if (result.id.isNotEmpty) {
|
||||||
await ref
|
await ref
|
||||||
.read(gopeedServiceProvider)
|
.read(gopeedServiceProvider)
|
||||||
.createTask(
|
.createTask(
|
||||||
CreateTask(
|
CreateTask(
|
||||||
rid: result.id,
|
rid: result.id,
|
||||||
opts: _buildOptions(selectFiles: selected),
|
opts: _buildOptions(selectFiles: selected, omitChecksum: isMultiFile),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -1113,11 +1141,11 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
opts: api_options.Options(
|
opts: api_options.Options(
|
||||||
name: file.name,
|
name: file.name,
|
||||||
path: path.join(_directoryController.text.trim(), result.res.name, file.path),
|
path: path.join(_directoryController.text.trim(), result.res.name, file.path),
|
||||||
extra: _buildOptions().extra,
|
extra: _buildOptions(omitChecksum: isMultiFile).extra,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions()));
|
await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions(omitChecksum: isMultiFile)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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": "فك الضغط تلقائياً",
|
"autoExtract": "فك الضغط تلقائياً",
|
||||||
"archivePassword": "كلمة مرور الملف المضغوط",
|
"archivePassword": "كلمة مرور الملف المضغوط",
|
||||||
"archivePasswordHint": "اتركه فارغاً إذا لم يكن محمياً بكلمة مرور",
|
"archivePasswordHint": "اتركه فارغاً إذا لم يكن محمياً بكلمة مرور",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "حذف الملف المضغوط بعد فك الضغط",
|
"deleteAfterExtract": "حذف الملف المضغوط بعد فك الضغط",
|
||||||
"extracting": "جاري فك الضغط",
|
"extracting": "جاري فك الضغط",
|
||||||
"extractDone": "تم فك الضغط بنجاح",
|
"extractDone": "تم فك الضغط بنجاح",
|
||||||
|
|||||||
@@ -171,6 +171,11 @@
|
|||||||
"autoExtract": "Arxivləri Avtomatik Çıxar",
|
"autoExtract": "Arxivləri Avtomatik Çıxar",
|
||||||
"archivePassword": "Arxiv Parolu",
|
"archivePassword": "Arxiv Parolu",
|
||||||
"archivePasswordHint": "Parolla qorunmayıbsa boş buraxın",
|
"archivePasswordHint": "Parolla qorunmayıbsa boş buraxın",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "Çıxarıldıqdan sonra arxivi sil",
|
"deleteAfterExtract": "Çıxarıldıqdan sonra arxivi sil",
|
||||||
"extracting": "Çıxarılır",
|
"extracting": "Çıxarılır",
|
||||||
"extractDone": "Çıxarma tamamlandı",
|
"extractDone": "Çıxarma tamamlandı",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "Extreu els comprimits automàticament",
|
"autoExtract": "Extreu els comprimits automàticament",
|
||||||
"archivePassword": "Contrasenya del comprimit",
|
"archivePassword": "Contrasenya del comprimit",
|
||||||
"archivePasswordHint": "Deixeu-lo en blanc si no està protegit amb contrasenya",
|
"archivePasswordHint": "Deixeu-lo en blanc si no està protegit amb contrasenya",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "Suprimeix el comprimit després de l'extracció",
|
"deleteAfterExtract": "Suprimeix el comprimit després de l'extracció",
|
||||||
"extracting": "S'està extraient",
|
"extracting": "S'està extraient",
|
||||||
"extractDone": "Extracció completada",
|
"extractDone": "Extracció completada",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "Archive automatisch entpacken",
|
"autoExtract": "Archive automatisch entpacken",
|
||||||
"archivePassword": "Archiv-Passwort",
|
"archivePassword": "Archiv-Passwort",
|
||||||
"archivePasswordHint": "Leer lassen, wenn nicht passwortgeschützt",
|
"archivePasswordHint": "Leer lassen, wenn nicht passwortgeschützt",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "Archiv nach Entpacken löschen",
|
"deleteAfterExtract": "Archiv nach Entpacken löschen",
|
||||||
"extracting": "Wird entpackt",
|
"extracting": "Wird entpackt",
|
||||||
"extractDone": "Entpacken abgeschlossen",
|
"extractDone": "Entpacken abgeschlossen",
|
||||||
|
|||||||
@@ -178,6 +178,11 @@
|
|||||||
"autoExtract": "Extract Archives Automatically",
|
"autoExtract": "Extract Archives Automatically",
|
||||||
"archivePassword": "Archive Password",
|
"archivePassword": "Archive Password",
|
||||||
"archivePasswordHint": "Leave empty if not password protected",
|
"archivePasswordHint": "Leave empty if not password protected",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "Delete Archives After Extraction",
|
"deleteAfterExtract": "Delete Archives After Extraction",
|
||||||
"extracting": "Extracting",
|
"extracting": "Extracting",
|
||||||
"extractDone": "Extraction completed",
|
"extractDone": "Extraction completed",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "Extraer archivos automáticamente",
|
"autoExtract": "Extraer archivos automáticamente",
|
||||||
"archivePassword": "Contraseña del archivo",
|
"archivePassword": "Contraseña del archivo",
|
||||||
"archivePasswordHint": "Dejar vacío si no tiene contraseña",
|
"archivePasswordHint": "Dejar vacío si no tiene contraseña",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "Eliminar archivo después de extraer",
|
"deleteAfterExtract": "Eliminar archivo después de extraer",
|
||||||
"extracting": "Extrayendo",
|
"extracting": "Extrayendo",
|
||||||
"extractDone": "Extracción completada",
|
"extractDone": "Extracción completada",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "استخراج خودکار بایگانیها",
|
"autoExtract": "استخراج خودکار بایگانیها",
|
||||||
"archivePassword": "رمز عبور بایگانی",
|
"archivePassword": "رمز عبور بایگانی",
|
||||||
"archivePasswordHint": "اگر رمز ندارد خالی بگذارید",
|
"archivePasswordHint": "اگر رمز ندارد خالی بگذارید",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "حذف آرشیو پس از استخراج",
|
"deleteAfterExtract": "حذف آرشیو پس از استخراج",
|
||||||
"extracting": "در حال استخراج",
|
"extracting": "در حال استخراج",
|
||||||
"extractDone": "استخراج کامل شد",
|
"extractDone": "استخراج کامل شد",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "Extraire automatiquement les archives",
|
"autoExtract": "Extraire automatiquement les archives",
|
||||||
"archivePassword": "Mot de passe de l'archive",
|
"archivePassword": "Mot de passe de l'archive",
|
||||||
"archivePasswordHint": "Laisser vide si non protégé par mot de passe",
|
"archivePasswordHint": "Laisser vide si non protégé par mot de passe",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "Supprimer l'archive après extraction",
|
"deleteAfterExtract": "Supprimer l'archive après extraction",
|
||||||
"extracting": "Extraction en cours",
|
"extracting": "Extraction en cours",
|
||||||
"extractDone": "Extraction terminée",
|
"extractDone": "Extraction terminée",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "Archívumok automatikus kicsomagolása",
|
"autoExtract": "Archívumok automatikus kicsomagolása",
|
||||||
"archivePassword": "Archívum jelszó",
|
"archivePassword": "Archívum jelszó",
|
||||||
"archivePasswordHint": "Hagyja üresen, ha nincs jelszóval védve",
|
"archivePasswordHint": "Hagyja üresen, ha nincs jelszóval védve",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "Archívum törlése kicsomagolás után",
|
"deleteAfterExtract": "Archívum törlése kicsomagolás után",
|
||||||
"extracting": "Kicsomagolás",
|
"extracting": "Kicsomagolás",
|
||||||
"extractDone": "Kicsomagolás befejezve",
|
"extractDone": "Kicsomagolás befejezve",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "Ekstrak Arsip Otomatis",
|
"autoExtract": "Ekstrak Arsip Otomatis",
|
||||||
"archivePassword": "Kata Sandi Arsip",
|
"archivePassword": "Kata Sandi Arsip",
|
||||||
"archivePasswordHint": "Kosongkan jika tidak dilindungi kata sandi",
|
"archivePasswordHint": "Kosongkan jika tidak dilindungi kata sandi",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "Hapus arsip setelah ekstraksi",
|
"deleteAfterExtract": "Hapus arsip setelah ekstraksi",
|
||||||
"extracting": "Mengekstrak",
|
"extracting": "Mengekstrak",
|
||||||
"extractDone": "Ekstraksi selesai",
|
"extractDone": "Ekstraksi selesai",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "Estrai automaticamente gli archivi",
|
"autoExtract": "Estrai automaticamente gli archivi",
|
||||||
"archivePassword": "Password archivio",
|
"archivePassword": "Password archivio",
|
||||||
"archivePasswordHint": "Lasciare vuoto se non protetto da password",
|
"archivePasswordHint": "Lasciare vuoto se non protetto da password",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "Elimina l'archivio dopo l'estrazione",
|
"deleteAfterExtract": "Elimina l'archivio dopo l'estrazione",
|
||||||
"extracting": "Estrazione in corso",
|
"extracting": "Estrazione in corso",
|
||||||
"extractDone": "Estrazione completata",
|
"extractDone": "Estrazione completata",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "アーカイブを自動展開",
|
"autoExtract": "アーカイブを自動展開",
|
||||||
"archivePassword": "アーカイブパスワード",
|
"archivePassword": "アーカイブパスワード",
|
||||||
"archivePasswordHint": "パスワードなしの場合は空のままにしてください",
|
"archivePasswordHint": "パスワードなしの場合は空のままにしてください",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "展開後にアーカイブを削除",
|
"deleteAfterExtract": "展開後にアーカイブを削除",
|
||||||
"extracting": "展開中",
|
"extracting": "展開中",
|
||||||
"extractDone": "展開完了",
|
"extractDone": "展開完了",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "압축 파일 자동 압축 해제",
|
"autoExtract": "압축 파일 자동 압축 해제",
|
||||||
"archivePassword": "압축 파일 비밀번호",
|
"archivePassword": "압축 파일 비밀번호",
|
||||||
"archivePasswordHint": "비밀번호가 없으면 비워 두세요",
|
"archivePasswordHint": "비밀번호가 없으면 비워 두세요",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "압축 해제 후 압축 파일 삭제",
|
"deleteAfterExtract": "압축 해제 후 압축 파일 삭제",
|
||||||
"extracting": "압축 해제 중",
|
"extracting": "압축 해제 중",
|
||||||
"extractDone": "압축 해제 완료",
|
"extractDone": "압축 해제 완료",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "Automatycznie wyodrębnij archiwa",
|
"autoExtract": "Automatycznie wyodrębnij archiwa",
|
||||||
"archivePassword": "Hasło archiwum",
|
"archivePassword": "Hasło archiwum",
|
||||||
"archivePasswordHint": "Pozostaw puste, jeśli nie ma hasła",
|
"archivePasswordHint": "Pozostaw puste, jeśli nie ma hasła",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "Usuń archiwum po rozpakowaniu",
|
"deleteAfterExtract": "Usuń archiwum po rozpakowaniu",
|
||||||
"extracting": "Rozpakowywanie",
|
"extracting": "Rozpakowywanie",
|
||||||
"extractDone": "Rozpakowywanie zakończone",
|
"extractDone": "Rozpakowywanie zakończone",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "Extrair arquivos automaticamente",
|
"autoExtract": "Extrair arquivos automaticamente",
|
||||||
"archivePassword": "Senha do arquivo",
|
"archivePassword": "Senha do arquivo",
|
||||||
"archivePasswordHint": "Deixar vazio se não estiver protegido por senha",
|
"archivePasswordHint": "Deixar vazio se não estiver protegido por senha",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "Excluir arquivo após extração",
|
"deleteAfterExtract": "Excluir arquivo após extração",
|
||||||
"extracting": "Extraindo",
|
"extracting": "Extraindo",
|
||||||
"extractDone": "Extração concluída",
|
"extractDone": "Extração concluída",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "Автоматически распаковать архивы",
|
"autoExtract": "Автоматически распаковать архивы",
|
||||||
"archivePassword": "Пароль архива",
|
"archivePassword": "Пароль архива",
|
||||||
"archivePasswordHint": "Оставить пустым, если архив не защищён паролем",
|
"archivePasswordHint": "Оставить пустым, если архив не защищён паролем",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "Удалить архив после распаковки",
|
"deleteAfterExtract": "Удалить архив после распаковки",
|
||||||
"extracting": "Распаковка",
|
"extracting": "Распаковка",
|
||||||
"extractDone": "Распаковка завершена",
|
"extractDone": "Распаковка завершена",
|
||||||
|
|||||||
@@ -178,6 +178,11 @@
|
|||||||
"autoExtract": "Automaticky rozbaliť archívy",
|
"autoExtract": "Automaticky rozbaliť archívy",
|
||||||
"archivePassword": "Heslo archívu",
|
"archivePassword": "Heslo archívu",
|
||||||
"archivePasswordHint": "Nechajte prázdne, ak nie je chránený heslom",
|
"archivePasswordHint": "Nechajte prázdne, ak nie je chránený heslom",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "Po rozbalení odstrániť archívy",
|
"deleteAfterExtract": "Po rozbalení odstrániť archívy",
|
||||||
"extracting": "Rozbaľovanie",
|
"extracting": "Rozbaľovanie",
|
||||||
"extractDone": "Rozbalenie dokončené",
|
"extractDone": "Rozbalenie dokončené",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "காப்பகங்களை தானாக பிரித்தெடு",
|
"autoExtract": "காப்பகங்களை தானாக பிரித்தெடு",
|
||||||
"archivePassword": "காப்பக கடவுச்சொல்",
|
"archivePassword": "காப்பக கடவுச்சொல்",
|
||||||
"archivePasswordHint": "கடவுச்சொல் இல்லையென்றால் காலியாக விடவும்",
|
"archivePasswordHint": "கடவுச்சொல் இல்லையென்றால் காலியாக விடவும்",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "பிரித்தெடுத்த பிறகு காப்பகத்தை நீக்கு",
|
"deleteAfterExtract": "பிரித்தெடுத்த பிறகு காப்பகத்தை நீக்கு",
|
||||||
"extracting": "பிரித்தெடுக்கிறது",
|
"extracting": "பிரித்தெடுக்கிறது",
|
||||||
"extractDone": "பிரித்தெடுத்தல் முடிந்தது",
|
"extractDone": "பிரித்தெடுத்தல் முடிந்தது",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "Arşivleri Otomatik Çıkar",
|
"autoExtract": "Arşivleri Otomatik Çıkar",
|
||||||
"archivePassword": "Arşiv Şifresi",
|
"archivePassword": "Arşiv Şifresi",
|
||||||
"archivePasswordHint": "Şifre korumalı değilse boş bırakın",
|
"archivePasswordHint": "Şifre korumalı değilse boş bırakın",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "Çıkardıktan sonra arşivi sil",
|
"deleteAfterExtract": "Çıkardıktan sonra arşivi sil",
|
||||||
"extracting": "Çıkarılıyor",
|
"extracting": "Çıkarılıyor",
|
||||||
"extractDone": "Çıkarma tamamlandı",
|
"extractDone": "Çıkarma tamamlandı",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "Автоматично розпаковувати архіви",
|
"autoExtract": "Автоматично розпаковувати архіви",
|
||||||
"archivePassword": "Пароль архіву",
|
"archivePassword": "Пароль архіву",
|
||||||
"archivePasswordHint": "Залишити порожнім, якщо архів не захищений паролем",
|
"archivePasswordHint": "Залишити порожнім, якщо архів не захищений паролем",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "Видалити архів після розпакування",
|
"deleteAfterExtract": "Видалити архів після розпакування",
|
||||||
"extracting": "Розпакування",
|
"extracting": "Розпакування",
|
||||||
"extractDone": "Розпакування завершено",
|
"extractDone": "Розпакування завершено",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "Tự động giải nén tập tin nén",
|
"autoExtract": "Tự động giải nén tập tin nén",
|
||||||
"archivePassword": "Mật khẩu tập tin nén",
|
"archivePassword": "Mật khẩu tập tin nén",
|
||||||
"archivePasswordHint": "Để trống nếu không có mật khẩu",
|
"archivePasswordHint": "Để trống nếu không có mật khẩu",
|
||||||
|
"checksumAlgorithm": "Checksum Algorithm",
|
||||||
|
"checksumHash": "Checksum Hash",
|
||||||
|
"checksumExpectedHint": "Expected hash (optional)",
|
||||||
|
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
|
||||||
|
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
|
||||||
"deleteAfterExtract": "Xóa tập tin nén sau khi giải nén",
|
"deleteAfterExtract": "Xóa tập tin nén sau khi giải nén",
|
||||||
"extracting": "Đang giải nén",
|
"extracting": "Đang giải nén",
|
||||||
"extractDone": "Giải nén hoàn tất",
|
"extractDone": "Giải nén hoàn tất",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "自动解压压缩包",
|
"autoExtract": "自动解压压缩包",
|
||||||
"archivePassword": "压缩包密码",
|
"archivePassword": "压缩包密码",
|
||||||
"archivePasswordHint": "如无密码请留空",
|
"archivePasswordHint": "如无密码请留空",
|
||||||
|
"checksumAlgorithm": "校验和算法",
|
||||||
|
"checksumHash": "校验和",
|
||||||
|
"checksumExpectedHint": "预期哈希值(可选)",
|
||||||
|
"checksumInvalidLength": "{algorithm} 校验和哈希长度无效:应为 {length} 个字符",
|
||||||
|
"checksumInvalidHex": "校验和哈希无效:应为十六进制字符(0-9,a-f)",
|
||||||
"deleteAfterExtract": "解压后删除压缩包",
|
"deleteAfterExtract": "解压后删除压缩包",
|
||||||
"extracting": "正在解压",
|
"extracting": "正在解压",
|
||||||
"extractDone": "解压完成",
|
"extractDone": "解压完成",
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
"autoExtract": "自動解壓縮壓縮包",
|
"autoExtract": "自動解壓縮壓縮包",
|
||||||
"archivePassword": "壓縮包密碼",
|
"archivePassword": "壓縮包密碼",
|
||||||
"archivePasswordHint": "如無密碼請留空",
|
"archivePasswordHint": "如無密碼請留空",
|
||||||
|
"checksumAlgorithm": "校驗和演算法",
|
||||||
|
"checksumHash": "校驗和",
|
||||||
|
"checksumExpectedHint": "預期雜湊值(可選)",
|
||||||
|
"checksumInvalidLength": "{algorithm} 校驗和雜湊長度無效:應為 {length} 個字元",
|
||||||
|
"checksumInvalidHex": "校驗和雜湊無效:應為十六進位字元(0-9,a-f)",
|
||||||
"deleteAfterExtract": "解壓後刪除壓縮檔",
|
"deleteAfterExtract": "解壓後刪除壓縮檔",
|
||||||
"extracting": "正在解壓",
|
"extracting": "正在解壓",
|
||||||
"extractDone": "解壓完成",
|
"extractDone": "解壓完成",
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ class AppDesignTokens {
|
|||||||
|
|
||||||
static const double windowHeaderHeight = 30;
|
static const double windowHeaderHeight = 30;
|
||||||
static const double windowRadius = 12;
|
static const double windowRadius = 12;
|
||||||
// Leave room for native macOS traffic lights while keeping rail widths consistent across platforms.
|
static const double railWidth = 68;
|
||||||
static const double railWidth = 78;
|
|
||||||
static const double filterSidebarWidth = 192;
|
static const double filterSidebarWidth = 192;
|
||||||
static const double contentHeaderHeight = 48;
|
static const double contentHeaderHeight = 48;
|
||||||
static const double taskRowHeight = 72;
|
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', () {
|
test('create-task child window has a localized native title', () {
|
||||||
expect(
|
expect(
|
||||||
AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('en')),
|
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', () {
|
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/theme/app_theme.dart';
|
||||||
import 'package:gopeed/shared/widgets/app_loading_button.dart';
|
import 'package:gopeed/shared/widgets/app_loading_button.dart';
|
||||||
import 'package:gopeed/shared/widgets/app_tooltip.dart';
|
import 'package:gopeed/shared/widgets/app_tooltip.dart';
|
||||||
|
import 'package:gopeed/l10n/app_localizations.dart';
|
||||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -611,6 +612,355 @@ void main() {
|
|||||||
expect(find.byKey(const ValueKey('create-history-clear')), findsOneWidget);
|
expect(find.byKey(const ValueKey('create-history-clear')), findsOneWidget);
|
||||||
expect(find.byKey(const ValueKey('create-history-close')), findsOneWidget);
|
expect(find.byKey(const ValueKey('create-history-close')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('checksum state is reset for each incoming task and not inherited (T1)', (tester) async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
try {
|
||||||
|
tester.view.physicalSize = const Size(700, 900);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
CreateTask? submitted;
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||||
|
..bind(GopeedMethods.createTask, (task) {
|
||||||
|
submitted = task;
|
||||||
|
return 'task-id';
|
||||||
|
})
|
||||||
|
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||||
|
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
final pending = container.read(pendingCreateTaskProvider.notifier);
|
||||||
|
// Task A: with checksum
|
||||||
|
pending.set(
|
||||||
|
CreateTask(
|
||||||
|
req: Request(url: 'https://example.com/taskA.zip'),
|
||||||
|
opts: Options(
|
||||||
|
extra: OptsExtraHttp(
|
||||||
|
checksum: ChecksumOption(
|
||||||
|
algorithm: 'sha256',
|
||||||
|
expected: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||||
|
),
|
||||||
|
).toJson(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final router = GoRouter(
|
||||||
|
initialLocation: '/create',
|
||||||
|
routes: [
|
||||||
|
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
|
||||||
|
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(router.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: shad.ShadcnApp.router(
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
materialTheme: AppTheme.materialLight(),
|
||||||
|
routerConfig: router,
|
||||||
|
builder: (_, child) => AppComponentThemes(child: child!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Expand advanced
|
||||||
|
await tester.tap(find.text('Advanced'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(_fieldText(tester, 'create-task-checksum-expected'), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
||||||
|
|
||||||
|
// Task B form 1: null opts
|
||||||
|
pending.set(CreateTask(req: Request(url: 'https://example.com/taskB1.zip')));
|
||||||
|
router.go('/create');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(_fieldText(tester, 'create-task-checksum-expected'), '');
|
||||||
|
|
||||||
|
// Task B form 2: empty Options
|
||||||
|
pending.set(CreateTask(req: Request(url: 'https://example.com/taskB2.zip'), opts: Options()));
|
||||||
|
router.go('/create');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(_fieldText(tester, 'create-task-checksum-expected'), '');
|
||||||
|
|
||||||
|
// Task B form 3: HTTP extra with no checksum
|
||||||
|
pending.set(
|
||||||
|
CreateTask(
|
||||||
|
req: Request(url: 'https://example.com/taskB3.zip'),
|
||||||
|
opts: Options(extra: OptsExtraHttp(connections: 8).toJson()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
router.go('/create');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(_fieldText(tester, 'create-task-checksum-expected'), '');
|
||||||
|
|
||||||
|
// Submit task B3 and ensure no checksum is sent
|
||||||
|
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-toggle')));
|
||||||
|
await tester.tap(find.text('Confirm'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(submitted?.opts?.extra, isNotNull);
|
||||||
|
final extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
|
||||||
|
expect(extra.checksum, isNull);
|
||||||
|
} finally {
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('checksum input validation blocks submission on invalid input (T3-UI)', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1024, 1200);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
CreateTask? submitted;
|
||||||
|
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getConfig, (_) => config)
|
||||||
|
..bind(GopeedMethods.createTask, (task) {
|
||||||
|
submitted = task;
|
||||||
|
return 'task-id';
|
||||||
|
})
|
||||||
|
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||||
|
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
final pending = container.read(pendingCreateTaskProvider.notifier);
|
||||||
|
pending.set(CreateTask(req: Request(url: 'https://example.com/file.zip')));
|
||||||
|
|
||||||
|
final router = GoRouter(
|
||||||
|
initialLocation: '/create',
|
||||||
|
routes: [
|
||||||
|
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
|
||||||
|
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(router.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: shad.ShadcnApp.router(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
materialTheme: AppTheme.materialLight(),
|
||||||
|
routerConfig: router,
|
||||||
|
builder: (_, child) => AppComponentThemes(child: child!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Expand advanced options
|
||||||
|
await tester.tap(find.text('Advanced'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Test 1: "abc" for SHA-256
|
||||||
|
await tester.tap(find.text('SHA-256'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'abc');
|
||||||
|
await tester.tap(find.text('Confirm'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(submitted, isNull);
|
||||||
|
|
||||||
|
// Test 2: 64 x "g" (non-hex) for SHA-256
|
||||||
|
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'g' * 64);
|
||||||
|
await tester.tap(find.text('Confirm'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(submitted, isNull);
|
||||||
|
|
||||||
|
// Test 3: 63 x "a" (invalid length) for SHA-256
|
||||||
|
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'a' * 63);
|
||||||
|
await tester.tap(find.text('Confirm'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(submitted, isNull);
|
||||||
|
|
||||||
|
// Test 4: 65 x "a" (invalid length) for SHA-256
|
||||||
|
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'a' * 65);
|
||||||
|
await tester.tap(find.text('Confirm'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(submitted, isNull);
|
||||||
|
|
||||||
|
// Test 5: MD5 with sha256-length hash
|
||||||
|
await tester.tap(find.text('MD5'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'a' * 64);
|
||||||
|
await tester.tap(find.text('Confirm'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(submitted, isNull);
|
||||||
|
|
||||||
|
// Test 6: Valid MD5 in UPPER case
|
||||||
|
submitted = null;
|
||||||
|
await tester.tap(find.text('MD5'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), '0123456789ABCDEF0123456789ABCDEF');
|
||||||
|
await tester.tap(find.text('Confirm'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(submitted, isNotNull);
|
||||||
|
var extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
|
||||||
|
expect(extra.checksum?.algorithm, 'md5');
|
||||||
|
expect(extra.checksum?.expected, '0123456789ABCDEF0123456789ABCDEF');
|
||||||
|
|
||||||
|
// Test 7: Valid SHA-1 in lower case
|
||||||
|
submitted = null;
|
||||||
|
pending.set(CreateTask(req: Request(url: 'https://example.com/file2.zip')));
|
||||||
|
router.go('/create');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Advanced'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('SHA-1'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||||
|
await tester.tap(find.text('Confirm'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(submitted, isNotNull);
|
||||||
|
extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
|
||||||
|
expect(extra.checksum?.algorithm, 'sha1');
|
||||||
|
expect(extra.checksum?.expected, 'da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
||||||
|
|
||||||
|
// Test 8: Valid SHA-256 in lower case
|
||||||
|
submitted = null;
|
||||||
|
pending.set(CreateTask(req: Request(url: 'https://example.com/file3.zip')));
|
||||||
|
router.go('/create');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Advanced'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('SHA-256'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
||||||
|
await tester.tap(find.text('Confirm'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(submitted, isNotNull);
|
||||||
|
extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
|
||||||
|
expect(extra.checksum?.algorithm, 'sha256');
|
||||||
|
expect(extra.checksum?.expected, 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
||||||
|
|
||||||
|
// Test 9: Omitted checksum (empty hash)
|
||||||
|
submitted = null;
|
||||||
|
pending.set(CreateTask(req: Request(url: 'https://example.com/file4.zip')));
|
||||||
|
router.go('/create');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Advanced'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), ' ');
|
||||||
|
await tester.tap(find.text('Confirm'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(submitted, isNotNull);
|
||||||
|
extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(submitted!.opts!.extra! as Map));
|
||||||
|
expect(extra.checksum, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('multi-URL and multi-file batch creation omits checksum (T4)', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1024, 1200);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final createdTasks = <CreateTask>[];
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||||
|
..bind(GopeedMethods.createTask, (task) {
|
||||||
|
createdTasks.add(task);
|
||||||
|
return 'task-id';
|
||||||
|
})
|
||||||
|
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||||
|
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
final router = GoRouter(
|
||||||
|
initialLocation: '/create',
|
||||||
|
routes: [
|
||||||
|
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
|
||||||
|
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(router.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
|
child: shad.ShadcnApp.router(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
materialTheme: AppTheme.materialLight(),
|
||||||
|
routerConfig: router,
|
||||||
|
builder: (_, child) => AppComponentThemes(child: child!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Enter 2 URLs
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('create-task-url-input')),
|
||||||
|
'https://example.com/file1.zip\nhttps://example.com/file2.zip',
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Advanced options should hide the checksum field
|
||||||
|
await tester.tap(find.text('Advanced'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('create-task-checksum-expected')), findsNothing);
|
||||||
|
|
||||||
|
// Confirm creation
|
||||||
|
await tester.tap(find.text('Confirm'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(createdTasks.length, 2);
|
||||||
|
for (final t in createdTasks) {
|
||||||
|
final extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(t.opts!.extra! as Map));
|
||||||
|
expect(extra.checksum, isNull);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('checksum UI uses localized labels (T5)', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(700, 1200);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||||
|
child: shad.ShadcnApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
materialTheme: AppTheme.materialLight(),
|
||||||
|
home: AppComponentThemes(
|
||||||
|
child: CreateTaskWindowPage(
|
||||||
|
initialTask: CreateTask(req: Request(url: 'https://example.com/file.zip')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Advanced'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Checksum Algorithm'), findsOneWidget);
|
||||||
|
expect(find.text('Checksum Hash'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
String _fieldText(WidgetTester tester, String key) {
|
String _fieldText(WidgetTester tester, String key) {
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ void main() {
|
|||||||
expect(find.text('Faster downloads'), findsOneWidget);
|
expect(find.text('Faster downloads'), findsOneWidget);
|
||||||
expect(find.text('下载更快'), findsNothing);
|
expect(find.text('下载更快'), findsNothing);
|
||||||
expect(find.text('Later'), findsOneWidget);
|
expect(find.text('Later'), findsOneWidget);
|
||||||
expect(find.text('Update'), findsOneWidget);
|
expect(find.text('Update Now'), findsOneWidget);
|
||||||
expect(find.text("What's New"), findsNothing);
|
expect(find.text("What's New"), findsNothing);
|
||||||
final versionText = tester.widget<Text>(find.text('2.0.0-beta.1 → 2.0.0-beta.2'));
|
final versionText = tester.widget<Text>(find.text('2.0.0-beta.1 → 2.0.0-beta.2'));
|
||||||
expect(versionText.style?.decoration, TextDecoration.none);
|
expect(versionText.style?.decoration, TextDecoration.none);
|
||||||
|
|||||||
@@ -630,9 +630,9 @@ void main() {
|
|||||||
tester.view.physicalSize = const Size(1024, 768);
|
tester.view.physicalSize = const Size(1024, 768);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(gridColumns(), 3);
|
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();
|
await tester.pumpAndSettle();
|
||||||
expect(gridColumns(), 3);
|
expect(gridColumns(), 3);
|
||||||
expect(tester.getSize(find.byKey(const ValueKey('extension-search-field-container'))).width, 240);
|
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);
|
tester.view.physicalSize = const Size(720, 608);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byType(PrimaryRail), findsOneWidget);
|
expect(find.byType(PrimaryRail), findsOneWidget);
|
||||||
expect(gridColumns(), 2);
|
|
||||||
final compactSearchRect = tester.getRect(find.byKey(const ValueKey('extension-search-field-container')));
|
final compactSearchRect = tester.getRect(find.byKey(const ValueKey('extension-search-field-container')));
|
||||||
expect(compactSearchRect.width, lessThan(240));
|
expect(compactSearchRect.width, lessThan(240));
|
||||||
expect(compactSearchRect.height, closeTo(sortRect.height, 0.01));
|
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 {
|
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(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [extensionsControllerProvider.overrideWith(CrowdedExtensionCardController.new)],
|
overrides: [extensionsControllerProvider.overrideWith(CrowdedExtensionCardController.new)],
|
||||||
@@ -684,7 +683,7 @@ void main() {
|
|||||||
final cardRect = tester.getRect(find.byKey(const ValueKey('extension-card-crowded-extension')));
|
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 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')));
|
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(statsRect.right, lessThanOrEqualTo(actionsRect.left - 8));
|
||||||
expect(actionsRect.right, lessThanOrEqualTo(cardRect.right - 14));
|
expect(actionsRect.right, lessThanOrEqualTo(cardRect.right - 14));
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
@@ -2677,8 +2676,8 @@ void main() {
|
|||||||
await tester.tap(find.byKey(const ValueKey('settings-language-select')));
|
await tester.tap(find.byKey(const ValueKey('settings-language-select')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('English'), findsOneWidget);
|
expect(find.text('English'), findsOneWidget);
|
||||||
expect(SettingsLanguageSelect.supportedValues, hasLength(23));
|
expect(SettingsLanguageSelect.supportedValues, hasLength(22));
|
||||||
expect(SettingsLanguageSelect.supportedValues, containsAll(<String>['az', 'zh', 'zh_TW', 'pt']));
|
expect(SettingsLanguageSelect.supportedValues, containsAll(<String>['zh', 'zh_TW', 'pt']));
|
||||||
expect(SettingsLanguageSelect.supportedValues, isNot(contains('zh_CN')));
|
expect(SettingsLanguageSelect.supportedValues, isNot(contains('zh_CN')));
|
||||||
expect(SettingsLanguageSelect.supportedValues, isNot(contains('pt_BR')));
|
expect(SettingsLanguageSelect.supportedValues, isNot(contains('pt_BR')));
|
||||||
expect(supportedLocaleFromConfig('zh_CN'), isNull);
|
expect(supportedLocaleFromConfig('zh_CN'), isNull);
|
||||||
|
|||||||
@@ -57,6 +57,17 @@ add_subdirectory("runner")
|
|||||||
# them to the application.
|
# them to the application.
|
||||||
include(flutter/generated_plugins.cmake)
|
include(flutter/generated_plugins.cmake)
|
||||||
|
|
||||||
|
# Temporary compatibility for legacy coroutine headers on MSVC 14.51.
|
||||||
|
# Remove once both Windows plugins use standard coroutines.
|
||||||
|
if(MSVC)
|
||||||
|
foreach(plugin IN ITEMS flutter_inappwebview_windows_plugin permission_handler_windows_plugin)
|
||||||
|
if(TARGET ${plugin})
|
||||||
|
target_compile_definitions(${plugin} PRIVATE
|
||||||
|
_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS)
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
|
||||||
# === Installation ===
|
# === Installation ===
|
||||||
# Support files are copied into place next to the executable, so that it can
|
# Support files are copied into place next to the executable, so that it can
|
||||||
|
|||||||
Reference in new issue
Block a user