mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-12 03:14:09 +00:00
Compare commits
192
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc96cff2ea | ||
|
|
15738644b0 | ||
|
|
44a5f9eefc | ||
|
|
c90c690836 | ||
|
|
dee1c2515f | ||
|
|
bbf1a9fd7d | ||
|
|
b3f462bb7d | ||
|
|
0d08004c49 | ||
|
|
1589d4cbe7 | ||
|
|
b6ce7756fb | ||
|
|
342c55fbd6 | ||
|
|
fe2f45e40d | ||
|
|
2d4a8cddc1 | ||
|
|
76c22c50d8 | ||
|
|
f400137ead | ||
|
|
a5ebbca318 | ||
|
|
8f2af5660e | ||
|
|
932a49c1d1 | ||
|
|
212c735b66 | ||
|
|
b5e8d61f01 | ||
|
|
929e2d2d7c | ||
|
|
e93437c95f | ||
|
|
7bc7efed5b | ||
|
|
1d8a502ac4 | ||
|
|
4b231f52c5 | ||
|
|
e956bd1ce5 | ||
|
|
68402c85d3 | ||
|
|
a480c5bd14 | ||
|
|
e779de285d | ||
|
|
fac17b0ccc | ||
|
|
8c728ffda7 | ||
|
|
1b4956b0ec | ||
|
|
2e5f8948be | ||
|
|
04ebca60cf | ||
|
|
948f6d5487 | ||
|
|
51b7abed48 | ||
|
|
d8758267c4 | ||
|
|
d94b59b165 | ||
|
|
f34cb9c971 | ||
|
|
172f4a3650 | ||
|
|
085ebf357a | ||
|
|
dae13dec01 | ||
|
|
a8f682559f | ||
|
|
3c0c019bc9 | ||
|
|
69b3e223ad | ||
|
|
183616fcc0 | ||
|
|
3415364ab7 | ||
|
|
1b00a16277 | ||
|
|
b33c00d90d | ||
|
|
7e07ea31cc | ||
|
|
c2da22f371 | ||
|
|
2fd8a96c5d | ||
|
|
1776b05782 | ||
|
|
ffbae58e6b | ||
|
|
2c7a86b21c | ||
|
|
4157e0a5bc | ||
|
|
dfe8e9a3fd | ||
|
|
7c33d978c4 | ||
|
|
534fbbb3a2 | ||
|
|
fdb2768008 | ||
|
|
27202cf99e | ||
|
|
a05eea3268 | ||
|
|
7496331ffd | ||
|
|
967ef25408 | ||
|
|
d1b2ae42af | ||
|
|
adeb7a2895 | ||
|
|
edfecefc16 | ||
|
|
f7dfc4226b | ||
|
|
edb8d3cbdc | ||
|
|
c6dd58848d | ||
|
|
88dd35cc46 | ||
|
|
051d4267a5 | ||
|
|
022fbf633b | ||
|
|
014221a1a7 | ||
|
|
6f4635e2eb | ||
|
|
a83e3a2b84 | ||
|
|
5bb040eaaf | ||
|
|
e27c7dddb4 | ||
|
|
5342be16d9 | ||
|
|
e0ced260e2 | ||
|
|
d41e27234d | ||
|
|
c703c52cbc | ||
|
|
b88144c864 | ||
|
|
273850ae3f | ||
|
|
07356033f9 | ||
|
|
166aee81be | ||
|
|
45d7b6f5bc | ||
|
|
5845e9ea5f | ||
|
|
f9410bbcc7 | ||
|
|
f1f17948c6 | ||
|
|
675028c64a | ||
|
|
b6d18a267b | ||
|
|
40cfb167f6 | ||
|
|
4fd1939732 | ||
|
|
3ed54ba93a | ||
|
|
fe64a328ac | ||
|
|
7e49b678f2 | ||
|
|
311e002e55 | ||
|
|
119fad9a94 | ||
|
|
e2601ea9fa | ||
|
|
a5eaa6b528 | ||
|
|
bed624bfce | ||
|
|
eec53c15b2 | ||
|
|
d58135db9f | ||
|
|
d642ae5018 | ||
|
|
e358123b22 | ||
|
|
ee9b16794e | ||
|
|
4f74101b3a | ||
|
|
c47b735fc4 | ||
|
|
ca98806dcc | ||
|
|
a0e84a75b1 | ||
|
|
14b22cf485 | ||
|
|
a3f6ec5885 | ||
|
|
f839c3ac93 | ||
|
|
67d4aa9554 | ||
|
|
c355f9ff5c | ||
|
|
59a7d40bfe | ||
|
|
5d83e91ce5 | ||
|
|
637004c8a8 | ||
|
|
e79b37d08c | ||
|
|
d393d27ec7 | ||
|
|
2e8672273c | ||
|
|
4802802828 | ||
|
|
3d372f98de | ||
|
|
d578fe96bc | ||
|
|
1262844363 | ||
|
|
91bea83692 | ||
|
|
e21bc5eeae | ||
|
|
6331060171 | ||
|
|
f3ec6b8ee9 | ||
|
|
9124976b5c | ||
|
|
9820e58de9 | ||
|
|
e890e593d9 | ||
|
|
6572e9e899 | ||
|
|
1c57b07575 | ||
|
|
6f12eb1f07 | ||
|
|
bd03d3b31c | ||
|
|
d330d8bea8 | ||
|
|
ee6b073bc1 | ||
|
|
a41c6c145c | ||
|
|
86c8383584 | ||
|
|
75462fe2be | ||
|
|
f35111e072 | ||
|
|
6aabe69f15 | ||
|
|
527539f3a1 | ||
|
|
2d239495a0 | ||
|
|
ddce649c8b | ||
|
|
29675858f2 | ||
|
|
1fc0ff6738 | ||
|
|
0851e27c6d | ||
|
|
3abc8d42fa | ||
|
|
71d7d96cc0 | ||
|
|
f254a82d9c | ||
|
|
c4ae3330b8 | ||
|
|
a45b6455be | ||
|
|
979988a7f4 | ||
|
|
9efe7ff4c9 | ||
|
|
77734c69bc | ||
|
|
22db46c68a | ||
|
|
99a62357ab | ||
|
|
9558042513 | ||
|
|
39912718c4 | ||
|
|
3b6b134f24 | ||
|
|
f0aa44455c | ||
|
|
99ed770017 | ||
|
|
f506b8f92f | ||
|
|
9b9fd12884 | ||
|
|
cea912b414 | ||
|
|
7c3d7269fe | ||
|
|
1554791e17 | ||
|
|
b0aecdcfc6 | ||
|
|
d2eb7b2a67 | ||
|
|
7112845c82 | ||
|
|
39820707be | ||
|
|
47db539d91 | ||
|
|
f32f7583e4 | ||
|
|
e3ae2865d6 | ||
|
|
1a8d73fbe6 | ||
|
|
5728caf7db | ||
|
|
77d95971ca | ||
|
|
2c81520029 | ||
|
|
8d45eb4fef | ||
|
|
31fa0e5bc9 | ||
|
|
0f60f40e3e | ||
|
|
b17c0fd7c8 | ||
|
|
a834ca0d74 | ||
|
|
ecfd07ccb0 | ||
|
|
d51eeb3f2c | ||
|
|
1bb3f74c9f | ||
|
|
f7be97667f | ||
|
|
607d1729e6 | ||
|
|
d12122ea86 |
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; f.prefetchStopCh != nil && i < 1000 && !f.prefetchDone.Load(); i++ {
|
for i := 0; i < 1000 && !f.prefetchDone.Load(); i++ {
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,22 +675,6 @@ 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
|
||||||
@@ -708,9 +692,6 @@ func (f *Fetcher) initOptions() error {
|
|||||||
extra.Connections = 1
|
extra.Connections = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := extra.Checksum.Validate(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -765,8 +746,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 {
|
||||||
fullRedownload := f.checksumFailed.Swap(false)
|
forceFullReset := f.checksumFailed.Swap(false)
|
||||||
if fullRedownload {
|
if forceFullReset {
|
||||||
// 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
|
||||||
@@ -774,7 +755,6 @@ 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 {
|
||||||
@@ -782,10 +762,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 := fullRedownload || (!conn.Completed && conn.State != connCompleted)
|
shouldReset := forceFullReset || (!conn.Completed && conn.State != connCompleted)
|
||||||
if shouldReset {
|
if shouldReset {
|
||||||
if fullRedownload || !f.hasSequentialPrefixLocked(conn) {
|
if !f.hasSequentialPrefixLocked(conn) {
|
||||||
f.resetConnectionForRestart(conn)
|
f.resetConnectionForRestart(conn, forceFullReset)
|
||||||
}
|
}
|
||||||
conn.State = connNotStarted
|
conn.State = connNotStarted
|
||||||
conn.failed = false
|
conn.failed = false
|
||||||
@@ -1158,7 +1138,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)
|
f.resetConnectionForRestart(conn, false)
|
||||||
}
|
}
|
||||||
f.connMu.Unlock()
|
f.connMu.Unlock()
|
||||||
}
|
}
|
||||||
@@ -1265,7 +1245,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)
|
f.resetConnectionForRestart(conn, false)
|
||||||
f.resolveDataPos.Store(0)
|
f.resolveDataPos.Store(0)
|
||||||
f.rangeValidatorPinned = false
|
f.rangeValidatorPinned = false
|
||||||
intentionalRestart = true
|
intentionalRestart = true
|
||||||
@@ -1710,7 +1690,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)
|
f.resetConnectionForRestart(conn, false)
|
||||||
f.resolveDataPos.Store(0)
|
f.resolveDataPos.Store(0)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1726,7 +1706,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)
|
f.resetConnectionForRestart(conn, false)
|
||||||
f.resolveDataPos.Store(0)
|
f.resolveDataPos.Store(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2109,10 +2089,19 @@ func (f *Fetcher) helpOtherConnection(helper *connection) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) resetConnectionForRestart(conn *connection) {
|
func (f *Fetcher) resetConnectionForRestart(conn *connection, force bool) {
|
||||||
if f.meta.Res.Range {
|
if f.meta.Res.Range && !force {
|
||||||
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.
|
||||||
@@ -2182,7 +2171,7 @@ func (f *Fetcher) resumeConnections() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !f.hasSequentialPrefixLocked(conn) {
|
if !f.hasSequentialPrefixLocked(conn) {
|
||||||
f.resetConnectionForRestart(conn)
|
f.resetConnectionForRestart(conn, false)
|
||||||
}
|
}
|
||||||
// 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,9 +7,7 @@ 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"
|
||||||
@@ -18,61 +16,29 @@ 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 33B")
|
corruptContent := []byte("Corrupt Initial Download Data!")
|
||||||
correctContent := []byte("Correct Retried Download Data 33B")
|
correctContent := []byte("Correct Retried Download Data!")
|
||||||
|
|
||||||
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 phase atomic.Int32 // 0: serve corruptContent, 1: serve correctContent
|
var reqCount atomic.Int32
|
||||||
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 phase.Load() == 0 {
|
if count == 1 {
|
||||||
|
// First request: serve corrupted content
|
||||||
served = corruptContent
|
served = corruptContent
|
||||||
} else {
|
} else {
|
||||||
|
// Second / subsequent request: serve correct content
|
||||||
served = correctContent
|
served = correctContent
|
||||||
}
|
}
|
||||||
serveRangeContent(w, r, served)
|
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(served)))
|
||||||
|
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||||
|
w.WriteHeader(gohttp.StatusOK)
|
||||||
|
_, _ = w.Write(served)
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
@@ -98,6 +64,19 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -112,9 +91,6 @@ 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)
|
||||||
@@ -144,245 +120,3 @@ 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,9 +137,15 @@ func TestFetcher_ChecksumVerification(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts)
|
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||||
|
t.Fatalf("Resolve error: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatalf("Start error: %v", err)
|
||||||
|
}
|
||||||
|
err := f.Wait()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Resolve expected error for unsupported algorithm, got nil")
|
t.Fatal("Wait 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)
|
||||||
@@ -268,37 +274,34 @@ func TestFetcher_ChecksumVerification(t *testing.T) {
|
|||||||
t.Fatalf("Wait error: %v", err)
|
t.Fatalf("Wait error: %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
t.Run("Invalid Checksum In Resolve Makes Zero Requests", func(t *testing.T) {
|
func TestResetConnectionForRestart_ForceOnRangeDownload(t *testing.T) {
|
||||||
var reqReceived atomic.Int32
|
f := buildFetcher()
|
||||||
customServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
f.meta = &fetcher.FetcherMeta{
|
||||||
reqReceived.Add(1)
|
Res: &base.Resource{
|
||||||
w.WriteHeader(gohttp.StatusOK)
|
Range: true,
|
||||||
_, _ = w.Write([]byte("some data"))
|
},
|
||||||
}))
|
}
|
||||||
defer customServer.Close()
|
conn := &connection{
|
||||||
|
Chunk: newChunk(0, 1024),
|
||||||
|
Downloaded: 512,
|
||||||
|
Completed: true,
|
||||||
|
}
|
||||||
|
conn.Chunk.Downloaded = 512
|
||||||
|
|
||||||
f := buildFetcher()
|
// Non-forced reset on range-supported download does not reset Chunk.Downloaded
|
||||||
defer f.Close()
|
f.resetConnectionForRestart(conn, false)
|
||||||
|
if conn.Chunk.Downloaded != 512 {
|
||||||
|
t.Fatalf("expected Chunk.Downloaded to remain 512 when force is false, got %d", conn.Chunk.Downloaded)
|
||||||
|
}
|
||||||
|
|
||||||
opts := &base.Options{
|
// Forced reset on range-supported download resets Chunk.Downloaded to 0
|
||||||
Path: t.TempDir(),
|
f.resetConnectionForRestart(conn, true)
|
||||||
Name: "test_invalid_checksum.txt",
|
if conn.Chunk.Downloaded != 0 {
|
||||||
Extra: &http.OptsExtra{
|
t.Fatalf("expected Chunk.Downloaded to be reset to 0 when force is true, got %d", conn.Chunk.Downloaded)
|
||||||
Connections: 1,
|
}
|
||||||
Checksum: &http.ChecksumOption{
|
if conn.Completed {
|
||||||
Algorithm: "sha256",
|
t.Fatal("expected Completed to be false after reset")
|
||||||
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())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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,11 +1,5 @@
|
|||||||
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"`
|
||||||
@@ -19,65 +13,6 @@ 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,162 +1,15 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"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: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
Expected: "abcdef1234567890",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if extra.Checksum.Algorithm != "sha256" || extra.Checksum.Expected != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
|
if extra.Checksum.Algorithm != "sha256" || extra.Checksum.Expected != "abcdef1234567890" {
|
||||||
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: `64px`
|
- Primary rail width: `78px` (reserves space for native macOS traffic lights)
|
||||||
- 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,6 +85,12 @@ 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,6 +11,8 @@ 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()});
|
||||||
@@ -26,20 +28,52 @@ 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 json = await _getJson(
|
final params = {
|
||||||
'/api/extensions',
|
'page': page.toString(),
|
||||||
queryParameters: {
|
'limit': limit.clamp(1, 100).toString(),
|
||||||
'page': page.toString(),
|
'sort': sort.name,
|
||||||
'limit': limit.clamp(1, 100).toString(),
|
'order': order.name,
|
||||||
'sort': sort.name,
|
'view': 'summary',
|
||||||
'order': order.name,
|
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
|
||||||
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>);
|
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>);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> reportExtensionInstall(String id) async {
|
Future<void> reportExtensionInstall(String id) async {
|
||||||
final uri = Uri.https(_host, '/api/extensions/install');
|
final uri = Uri.https(_host, '/api/extensions/install');
|
||||||
await proxyRequest(
|
await proxyRequest(
|
||||||
@@ -49,8 +83,11 @@ class GopeedSiteApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) async {
|
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) {
|
||||||
final uri = Uri.https(_host, path, queryParameters);
|
return _getJsonUri(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,6 +64,7 @@ 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,
|
||||||
@@ -84,6 +85,7 @@ 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) {
|
||||||
@@ -106,6 +108,7 @@ 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,63 +121,81 @@ 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() async {
|
Future<ExtensionsState> build() => _loadInitialData();
|
||||||
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);
|
||||||
final installed = await _loadInstalled(refreshUpdates: true, current: next);
|
await Future.wait([
|
||||||
next = state.value ?? installed;
|
_loadInstalled(refreshUpdates: true, current: next),
|
||||||
await _refreshStore(current: next);
|
_refreshStore(current: _current, forceRefresh: forceRefresh),
|
||||||
return state.value ?? next;
|
]);
|
||||||
|
return ref.mounted ? _current : next;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadInitialData() async {
|
Future<void> loadInitialData() async {
|
||||||
state = const AsyncValue.loading();
|
await _loadInitialData(forceRefresh: true);
|
||||||
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));
|
await _refreshStore(current: _current.copyWith(loadingStore: true), forceRefresh: 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 GopeedSiteApi.instance.getExtensions(
|
final page = await ref
|
||||||
page: pagination.page + 1,
|
.read(gopeedSiteApiProvider)
|
||||||
limit: pagination.limit,
|
.getExtensions(
|
||||||
sort: current.storeSort,
|
page: pagination.page + 1,
|
||||||
query: current.storeQuery,
|
limit: pagination.limit,
|
||||||
);
|
sort: current.storeSort,
|
||||||
|
query: current.storeQuery,
|
||||||
|
);
|
||||||
|
if (!ref.mounted || revision != _storeRevision) return;
|
||||||
state = AsyncValue.data(
|
state = AsyncValue.data(
|
||||||
_current.copyWith(
|
_current.copyWith(
|
||||||
storeExtensions: [..._current.storeExtensions, ...page.data],
|
// Cached pages can briefly overlap when stars/install counts change.
|
||||||
|
storeExtensions: {
|
||||||
|
for (final extension in _current.storeExtensions) extension.id: extension,
|
||||||
|
for (final extension in page.data) extension.id: extension,
|
||||||
|
}.values.toList(),
|
||||||
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();
|
await _refreshStore(current: _current);
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
await _refreshStore(current: _current);
|
||||||
}
|
}
|
||||||
|
|
||||||
void changeFilter(ExtensionListFilter filter) {
|
void changeFilter(ExtensionListFilter filter) {
|
||||||
@@ -257,23 +275,43 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> checkUpdate() async {
|
Future<void> checkUpdate() {
|
||||||
final flags = <String, String>{};
|
if (!ref.mounted) return Future.value();
|
||||||
for (final ext in _current.installedExtensions) {
|
_checkUpdatesAgain = true;
|
||||||
try {
|
// Installation/refresh can request a fresh scan while one is running.
|
||||||
final resp = await ref.read(gopeedServiceProvider).upgradeCheckExtension(ext.identity);
|
// Coalesce those requests instead of launching overlapping Git operations.
|
||||||
if (resp.newVersion.isNotEmpty) {
|
return _updateCheck ??= _runUpdateChecks().whenComplete(() => _updateCheck = null);
|
||||||
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 (_current.devMode) return;
|
if (!ref.mounted || _current.devMode) return;
|
||||||
_devModeCount = 0;
|
_devModeCount = 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -286,26 +324,40 @@ 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) {
|
||||||
await checkUpdate();
|
unawaited(checkUpdate());
|
||||||
}
|
}
|
||||||
return _current;
|
return _current;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshStore({required ExtensionsState current}) async {
|
Future<void> _refreshStore({required ExtensionsState current, bool forceRefresh = false}) async {
|
||||||
state = AsyncValue.data(current.copyWith(loadingStore: true));
|
final revision = ++_storeRevision;
|
||||||
final page = await GopeedSiteApi.instance.getExtensions(
|
state = AsyncValue.data(current.copyWith(loadingStore: true, loadingMoreStore: false));
|
||||||
page: 1,
|
try {
|
||||||
limit: 20,
|
final page = await ref
|
||||||
sort: _current.storeSort,
|
.read(gopeedSiteApiProvider)
|
||||||
query: _current.storeQuery,
|
.getExtensions(
|
||||||
);
|
page: 1,
|
||||||
state = AsyncValue.data(
|
limit: 20,
|
||||||
_current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false),
|
sort: current.storeSort,
|
||||||
);
|
query: current.storeQuery,
|
||||||
|
forceRefresh: forceRefresh,
|
||||||
|
);
|
||||||
|
if (!ref.mounted || revision != _storeRevision) return;
|
||||||
|
state = AsyncValue.data(
|
||||||
|
_current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
if (!ref.mounted || revision != _storeRevision) return;
|
||||||
|
state = AsyncValue.data(_current.copyWith(loadingStore: false));
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runBusy(String id, Future<void> Function() action) async {
|
Future<void> _runBusy(String id, Future<void> Function() action) async {
|
||||||
@@ -314,12 +366,15 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
|
|||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
} finally {
|
} finally {
|
||||||
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
|
if (ref.mounted) {
|
||||||
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
|
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
|
||||||
|
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _bumpStoreInstallCount(String id) {
|
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;
|
||||||
@@ -344,14 +399,17 @@ 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 GopeedSiteApi.instance.reportExtensionInstall(id);
|
await siteApi.reportExtensionInstall(id);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}());
|
}());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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,7 +30,8 @@ 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';
|
||||||
|
|
||||||
const _extensionCardMinWidth = 290.0;
|
// Keep three columns in the 1024px reference window with the wider primary rail.
|
||||||
|
const _extensionCardMinWidth = 280.0;
|
||||||
const _extensionGridSpacing = 10.0;
|
const _extensionGridSpacing = 10.0;
|
||||||
|
|
||||||
int _extensionGridColumnCount(double width) {
|
int _extensionGridColumnCount(double width) {
|
||||||
|
|||||||
+99
-50
@@ -19,6 +19,7 @@ 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';
|
||||||
@@ -63,7 +64,13 @@ 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 current = state?.findItem(item.id) ?? item;
|
final summary = state?.findItem(item.id) ?? item;
|
||||||
|
final summaryStore = summary.store;
|
||||||
|
final detailKey = summaryStore == null || summaryStore.hasDetails
|
||||||
|
? null
|
||||||
|
: (id: summaryStore.id, version: summaryStore.version);
|
||||||
|
final details = detailKey == null ? null : ref.watch(storeExtensionDetailsProvider(detailKey));
|
||||||
|
final current = ExtensionListItem(installed: summary.installed, store: details?.value ?? summaryStore);
|
||||||
final 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;
|
||||||
@@ -116,7 +123,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 (store != null) _ExtensionStats(store: store),
|
if (summaryStore != null) _ExtensionStats(store: summaryStore),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -164,13 +171,22 @@ class ExtensionDetailView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: mobile ? 24 : 28),
|
if (details?.isLoading == true)
|
||||||
_ExtensionReadme(
|
const Padding(
|
||||||
// Snapshot the version so upgrades reload the installed README.
|
key: ValueKey('extension-details-loading'),
|
||||||
key: ValueKey((current.id, installed?.version, installed?.devMode, installed?.devPath, store?.readme)),
|
padding: EdgeInsets.only(top: 24),
|
||||||
item: current,
|
child: Center(child: shad.CircularProgressIndicator()),
|
||||||
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)],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,7 +204,7 @@ class ExtensionDetailView extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ExtensionReadme extends StatefulWidget {
|
class _ExtensionReadme extends StatefulWidget {
|
||||||
const _ExtensionReadme({super.key, required this.item, required this.mobile});
|
const _ExtensionReadme({required this.item, required this.mobile});
|
||||||
|
|
||||||
final ExtensionListItem item;
|
final ExtensionListItem item;
|
||||||
final bool mobile;
|
final bool mobile;
|
||||||
@@ -198,7 +214,7 @@ class _ExtensionReadme extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ExtensionReadmeState extends State<_ExtensionReadme> {
|
class _ExtensionReadmeState extends State<_ExtensionReadme> {
|
||||||
late final _ReadmeInfo _readme;
|
late Future<_ReadmeInfo> _readme;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -206,48 +222,81 @@ 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);
|
||||||
final info = _readme;
|
return FutureBuilder<_ReadmeInfo>(
|
||||||
if (info.content.trim().isEmpty) return const SizedBox.shrink();
|
future: _readme,
|
||||||
return Padding(
|
builder: (context, snapshot) {
|
||||||
key: const ValueKey('extension-details-readme'),
|
final info = snapshot.data;
|
||||||
padding: EdgeInsets.only(top: widget.mobile ? 32 : 36),
|
if (info == null) {
|
||||||
child: Column(
|
return const SizedBox.shrink();
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
}
|
||||||
children: [
|
if (info.content.trim().isEmpty) {
|
||||||
Text(
|
return const SizedBox.shrink();
|
||||||
'README',
|
}
|
||||||
style: TextStyle(color: palette.textMuted, fontSize: 10.5, fontWeight: FontWeight.w800, letterSpacing: 1.2),
|
return Padding(
|
||||||
|
key: const ValueKey('extension-details-readme'),
|
||||||
|
padding: EdgeInsets.only(top: widget.mobile ? 32 : 36),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'README',
|
||||||
|
style: TextStyle(
|
||||||
|
color: palette.textMuted,
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
MarkdownBody(
|
||||||
|
data: info.content,
|
||||||
|
selectable: true,
|
||||||
|
styleSheet: _markdownStyle(palette, mobile: widget.mobile),
|
||||||
|
onTapLink: (_, href, _) {
|
||||||
|
final resolved = _resolveReadmeUrl(widget.item, href, info: info, forImage: false);
|
||||||
|
if (resolved != null) unawaited(_openUrl(resolved));
|
||||||
|
},
|
||||||
|
imageBuilder: (uri, title, alt) {
|
||||||
|
final resolved = _resolveReadmeUrl(widget.item, uri.toString(), info: info, forImage: true);
|
||||||
|
if (resolved == null) return const SizedBox.shrink();
|
||||||
|
final image = resolved.startsWith('file:')
|
||||||
|
? Image.file(
|
||||||
|
File(Uri.parse(resolved).toFilePath()),
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||||
|
)
|
||||||
|
: Image.network(
|
||||||
|
resolved,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
child: ClipRRect(borderRadius: BorderRadius.circular(6), child: image),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
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),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,7 +403,7 @@ MarkdownStyleSheet _markdownStyle(AppPalette palette, {required bool mobile}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_ReadmeInfo _loadReadme(ExtensionListItem item) {
|
Future<_ReadmeInfo> _loadReadme(ExtensionListItem item) async {
|
||||||
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),
|
||||||
Text(link.label),
|
Flexible(child: Text(link.label, maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 = 'md5';
|
String _checksumAlgorithm = 'sha256';
|
||||||
bool _skipVerifyCert = false;
|
bool _skipVerifyCert = false;
|
||||||
bool? _autoTorrent;
|
bool? _autoTorrent;
|
||||||
bool? _deleteTorrentAfterDownload;
|
bool? _deleteTorrentAfterDownload;
|
||||||
@@ -187,10 +187,6 @@ 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());
|
||||||
}
|
}
|
||||||
@@ -200,8 +196,6 @@ 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();
|
||||||
@@ -683,34 +677,32 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (_inputUrls().length <= 1) ...[
|
const SizedBox(height: 14),
|
||||||
const SizedBox(height: 14),
|
AppFormPair(
|
||||||
AppFormPair(
|
direction: stacked ? Axis.vertical : Axis.horizontal,
|
||||||
direction: stacked ? Axis.vertical : Axis.horizontal,
|
firstLabel: 'Checksum Algorithm',
|
||||||
firstLabel: context.l10n.checksumAlgorithm,
|
secondLabel: 'Checksum Hash',
|
||||||
secondLabel: context.l10n.checksumHash,
|
firstFlex: 382,
|
||||||
firstFlex: 382,
|
secondFlex: 618,
|
||||||
secondFlex: 618,
|
first: AppChoiceSegmentedControl<String>(
|
||||||
first: AppChoiceSegmentedControl<String>(
|
key: const ValueKey('create-task-checksum-algorithm'),
|
||||||
key: const ValueKey('create-task-checksum-algorithm'),
|
showIcons: false,
|
||||||
showIcons: false,
|
value: _checksumAlgorithm,
|
||||||
value: _checksumAlgorithm,
|
buttonKeyPrefix: 'create-task-checksum-algorithm',
|
||||||
buttonKeyPrefix: 'create-task-checksum-algorithm',
|
alignment: WrapAlignment.start,
|
||||||
alignment: WrapAlignment.start,
|
options: const [
|
||||||
options: const [
|
AppChoiceOption(value: 'md5', label: 'MD5', icon: Icons.tag),
|
||||||
AppChoiceOption(value: 'md5', label: 'MD5', icon: Icons.tag),
|
AppChoiceOption(value: 'sha1', label: 'SHA-1', icon: Icons.tag),
|
||||||
AppChoiceOption(value: 'sha1', label: 'SHA-1', icon: Icons.tag),
|
AppChoiceOption(value: 'sha256', label: 'SHA-256', 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,
|
||||||
@@ -993,25 +985,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -1084,10 +1057,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 [], bool omitChecksum = false}) {
|
api_options.Options _buildOptions({String? name, List<int> selectFiles = const []}) {
|
||||||
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 = (!omitChecksum && _inputUrls().length <= 1 && checksumExpected.isNotEmpty)
|
final checksum = checksumExpected.isNotEmpty
|
||||||
? api_options.ChecksumOption(
|
? api_options.ChecksumOption(
|
||||||
algorithm: _checksumAlgorithm,
|
algorithm: _checksumAlgorithm,
|
||||||
expected: checksumExpected,
|
expected: checksumExpected,
|
||||||
@@ -1120,14 +1093,13 @@ 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, omitChecksum: isMultiFile),
|
opts: _buildOptions(selectFiles: selected),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -1141,11 +1113,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(omitChecksum: isMultiFile).extra,
|
extra: _buildOptions().extra,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions(omitChecksum: isMultiFile)));
|
await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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,11 +164,6 @@
|
|||||||
"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,11 +171,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +178,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +178,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"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,11 +164,6 @@
|
|||||||
"autoExtract": "自动解压压缩包",
|
"autoExtract": "自动解压压缩包",
|
||||||
"archivePassword": "压缩包密码",
|
"archivePassword": "压缩包密码",
|
||||||
"archivePasswordHint": "如无密码请留空",
|
"archivePasswordHint": "如无密码请留空",
|
||||||
"checksumAlgorithm": "校验和算法",
|
|
||||||
"checksumHash": "校验和",
|
|
||||||
"checksumExpectedHint": "预期哈希值(可选)",
|
|
||||||
"checksumInvalidLength": "{algorithm} 校验和哈希长度无效:应为 {length} 个字符",
|
|
||||||
"checksumInvalidHex": "校验和哈希无效:应为十六进制字符(0-9,a-f)",
|
|
||||||
"deleteAfterExtract": "解压后删除压缩包",
|
"deleteAfterExtract": "解压后删除压缩包",
|
||||||
"extracting": "正在解压",
|
"extracting": "正在解压",
|
||||||
"extractDone": "解压完成",
|
"extractDone": "解压完成",
|
||||||
|
|||||||
@@ -164,11 +164,6 @@
|
|||||||
"autoExtract": "自動解壓縮壓縮包",
|
"autoExtract": "自動解壓縮壓縮包",
|
||||||
"archivePassword": "壓縮包密碼",
|
"archivePassword": "壓縮包密碼",
|
||||||
"archivePasswordHint": "如無密碼請留空",
|
"archivePasswordHint": "如無密碼請留空",
|
||||||
"checksumAlgorithm": "校驗和演算法",
|
|
||||||
"checksumHash": "校驗和",
|
|
||||||
"checksumExpectedHint": "預期雜湊值(可選)",
|
|
||||||
"checksumInvalidLength": "{algorithm} 校驗和雜湊長度無效:應為 {length} 個字元",
|
|
||||||
"checksumInvalidHex": "校驗和雜湊無效:應為十六進位字元(0-9,a-f)",
|
|
||||||
"deleteAfterExtract": "解壓後刪除壓縮檔",
|
"deleteAfterExtract": "解壓後刪除壓縮檔",
|
||||||
"extracting": "正在解壓",
|
"extracting": "正在解壓",
|
||||||
"extractDone": "解壓完成",
|
"extractDone": "解壓完成",
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ class AppDesignTokens {
|
|||||||
|
|
||||||
static const double windowHeaderHeight = 30;
|
static const double windowHeaderHeight = 30;
|
||||||
static const double windowRadius = 12;
|
static const double windowRadius = 12;
|
||||||
static const double railWidth = 68;
|
// Leave room for native macOS traffic lights while keeping rail widths consistent across platforms.
|
||||||
|
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;
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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', () {
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
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
@@ -0,0 +1,89 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
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,7 +26,6 @@ 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() {
|
||||||
@@ -612,355 +611,6 @@ 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 Now'), findsOneWidget);
|
expect(find.text('Update'), 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(290));
|
expect(tester.getSize(find.byKey(const ValueKey('extension-card-extension-0'))).width, greaterThanOrEqualTo(280));
|
||||||
|
|
||||||
tester.view.physicalSize = const Size(1028, 608);
|
tester.view.physicalSize = const Size(1024, 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,6 +644,7 @@ 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));
|
||||||
@@ -665,7 +666,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(722, 608));
|
await _setTestSize(tester, const Size(720, 608));
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [extensionsControllerProvider.overrideWith(CrowdedExtensionCardController.new)],
|
overrides: [extensionsControllerProvider.overrideWith(CrowdedExtensionCardController.new)],
|
||||||
@@ -683,7 +684,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(290, 0.01));
|
expect(cardRect.width, closeTo(284, 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);
|
||||||
@@ -2676,8 +2677,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(22));
|
expect(SettingsLanguageSelect.supportedValues, hasLength(23));
|
||||||
expect(SettingsLanguageSelect.supportedValues, containsAll(<String>['zh', 'zh_TW', 'pt']));
|
expect(SettingsLanguageSelect.supportedValues, containsAll(<String>['az', '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,17 +57,6 @@ 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