Compare commits

...
60 changed files with 2715 additions and 254 deletions

No files matched your search

+4
View File
@@ -10,6 +10,8 @@ type args struct {
url string url string
connections *int connections *int
dir *string dir *string
checksum *string
checksumAlgo *string
} }
func parse() *args { func parse() *args {
@@ -20,6 +22,8 @@ func parse() *args {
var args args var args args
args.connections = flag.Int("C", 16, "Concurrent connections.") args.connections = flag.Int("C", 16, "Concurrent connections.")
args.dir = flag.String("D", dir, "Store directory.") args.dir = flag.String("D", dir, "Store directory.")
args.checksum = flag.String("checksum", "", "Expected checksum hash (hex encoded).")
args.checksumAlgo = flag.String("checksum-algo", "md5", "Checksum algorithm: md5, sha1, or sha256.")
flag.Parse() flag.Parse()
t := flag.Args() t := flag.Args()
if len(t) > 0 { if len(t) > 0 {
+16 -1
View File
@@ -19,6 +19,18 @@ func main() {
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(1) wg.Add(1)
var checksumOpt *http.ChecksumOption
if args.checksum != nil && *args.checksum != "" {
algo := "md5"
if args.checksumAlgo != nil && *args.checksumAlgo != "" {
algo = *args.checksumAlgo
}
checksumOpt = &http.ChecksumOption{
Algorithm: algo,
Expected: *args.checksum,
}
}
_, err := download.Boot(). _, err := download.Boot().
URL(args.url). URL(args.url).
Listener(func(event *download.Event) { Listener(func(event *download.Event) {
@@ -44,7 +56,10 @@ func main() {
}). }).
Create(&base.Options{ Create(&base.Options{
Path: *args.dir, Path: *args.dir,
Extra: http.OptsExtra{Connections: *args.connections}, Extra: http.OptsExtra{
Connections: *args.connections,
Checksum: checksumOpt,
},
}) })
if err != nil { if err != nil {
panic(err) panic(err)
+108 -7
View File
@@ -2,8 +2,13 @@ package http
import ( import (
"context" "context"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"hash"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@@ -280,6 +285,12 @@ type Fetcher struct {
primaryReadyOnce sync.Once primaryReadyOnce sync.Once
primaryReadyCh chan struct{} primaryReadyCh chan struct{}
// checksumFailed marks that the last completed download failed
// checksum verification, so the next retry must force a full
// re-download instead of skipping connections that already
// reported Completed.
checksumFailed atomic.Bool
// Start pending mechanism // Start pending mechanism
startPending atomic.Bool startPending atomic.Bool
resolvedCh chan struct{} // Signal when resolve completes resolvedCh chan struct{} // Signal when resolve completes
@@ -609,7 +620,7 @@ func (f *Fetcher) stopPrefetchAndCopyData() int64 {
} }
// Wait for prefetch to finish (with timeout) // Wait for prefetch to finish (with timeout)
for i := 0; i < 1000 && !f.prefetchDone.Load(); i++ { for i := 0; f.prefetchStopCh != nil && i < 1000 && !f.prefetchDone.Load(); i++ {
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
} }
@@ -664,6 +675,22 @@ func (f *Fetcher) cleanupPrefetchFile() {
f.resolveFallback.Store(false) f.resolveFallback.Store(false)
} }
// prepareFullRedownload resets all fetcher connection and prefetch state
// after a checksum mismatch so the subsequent Start takes the fresh-start path.
func (f *Fetcher) prepareFullRedownload() {
f.connMu.Lock()
if f.meta.Res.Range {
f.connections = nil
f.resolveConn = nil
f.rangeReprobeEligible = false
f.rangeValidatorPinned = false
}
f.connMu.Unlock()
f.cleanupPrefetchFile()
f.resolveDataPos.Store(0)
}
// Options may be replaced between Resolve and Start when creating a resolved task. // Options may be replaced between Resolve and Start when creating a resolved task.
func (f *Fetcher) initOptions() error { func (f *Fetcher) initOptions() error {
opts := f.meta.Opts opts := f.meta.Opts
@@ -681,6 +708,9 @@ func (f *Fetcher) initOptions() error {
extra.Connections = 1 extra.Connections = 1
} }
} }
if err := extra.Checksum.Validate(); err != nil {
return err
}
return nil return nil
} }
@@ -735,11 +765,26 @@ 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)
if fullRedownload {
// Checksum mismatch: the file content itself is wrong even
// though every connection reported Completed. Truncate the
// corrupt file so stale bytes cannot linger if the retry is
// interrupted before every connection re-writes its chunk.
if err := os.Truncate(f.meta.SingleFilepath(), 0); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to reset file after checksum mismatch: %w", err)
}
f.prepareFullRedownload()
}
f.connMu.Lock() f.connMu.Lock()
for _, conn := range f.connections { for _, conn := range f.connections {
// Reset connections that can be retried // On a normal error, only reset connections that didn't
if !conn.Completed && conn.State != connCompleted { // finish. On a checksum mismatch, reset EVERY connection —
if !f.hasSequentialPrefixLocked(conn) { // "Completed" only means the download loop finished, not
// that the bytes were verified correct.
shouldReset := fullRedownload || (!conn.Completed && conn.State != connCompleted)
if shouldReset {
if fullRedownload || !f.hasSequentialPrefixLocked(conn) {
f.resetConnectionForRestart(conn) f.resetConnectionForRestart(conn)
} }
conn.State = connNotStarted conn.State = connNotStarted
@@ -970,8 +1015,14 @@ func (f *Fetcher) expandConnections() {
} }
f.fileMu.Unlock() f.fileMu.Unlock()
var err error
if chkErr := f.verifyChecksum(); chkErr != nil {
err = chkErr
f.setState(stateError)
} else {
f.setState(stateDone) f.setState(stateDone)
f.doneCh <- nil }
f.doneCh <- err
return return
} }
@@ -2067,7 +2118,7 @@ func (f *Fetcher) resetConnectionForRestart(conn *connection) {
// so pause/retry must restart instead of continuing from the old offset. // so pause/retry must restart instead of continuing from the old offset.
if conn.Chunk == nil { if conn.Chunk == nil {
conn.Chunk = newChunk(0, 0) conn.Chunk = newChunk(0, 0)
} else { } else if !f.meta.Res.Range {
conn.Chunk.Begin = 0 conn.Chunk.Begin = 0
conn.Chunk.setEnd(0) conn.Chunk.setEnd(0)
conn.Chunk.Downloaded = 0 conn.Chunk.Downloaded = 0
@@ -2238,11 +2289,16 @@ func (f *Fetcher) onDownloadComplete() {
} }
f.fileMu.Unlock() f.fileMu.Unlock()
if finalErr != nil { if finalErr == nil {
if chkErr := f.verifyChecksum(); chkErr != nil {
finalErr = chkErr
f.setState(stateError) f.setState(stateError)
} else { } else {
f.setState(stateDone) f.setState(stateDone)
} }
} else {
f.setState(stateError)
}
select { select {
case f.doneCh <- finalErr: case f.doneCh <- finalErr:
@@ -2250,6 +2306,51 @@ func (f *Fetcher) onDownloadComplete() {
} }
} }
func (f *Fetcher) verifyChecksum() error {
if f.meta == nil || f.meta.Opts == nil || f.meta.Opts.Extra == nil {
return nil
}
extra, ok := f.meta.Opts.Extra.(*fhttp.OptsExtra)
if !ok || extra.Checksum == nil {
return nil
}
chk := extra.Checksum
if chk.Algorithm == "" && chk.Expected == "" {
return nil
}
var h hash.Hash
switch strings.ToLower(strings.TrimSpace(chk.Algorithm)) {
case "md5":
h = md5.New()
case "sha1", "sha-1":
h = sha1.New()
case "sha256", "sha-256":
h = sha256.New()
default:
return fmt.Errorf("unsupported checksum algorithm: %s", chk.Algorithm)
}
filePath := f.meta.SingleFilepath()
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open file for checksum verification failed: %w", err)
}
defer file.Close()
buf := make([]byte, 64*1024)
if _, err := io.CopyBuffer(h, file, buf); err != nil {
return fmt.Errorf("calculate checksum failed: %w", err)
}
actual := hex.EncodeToString(h.Sum(nil))
expected := strings.ToLower(strings.TrimSpace(chk.Expected))
if !strings.EqualFold(actual, expected) {
f.checksumFailed.Store(true)
return fmt.Errorf("checksum mismatch: expected %s, got %s", expected, actual)
}
return nil
}
func (f *Fetcher) checkCompletion() bool { func (f *Fetcher) checkCompletion() bool {
// Check if all data has been downloaded // Check if all data has been downloaded
f.connMu.Lock() f.connMu.Lock()
@@ -0,0 +1,388 @@
package http
import (
"crypto/sha256"
"encoding/hex"
"fmt"
gohttp "net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
)
// serveRangeContent serves byte range requests or full responses from data.
func serveRangeContent(w gohttp.ResponseWriter, r *gohttp.Request, data []byte) {
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
rangeHdr := r.Header.Get(base.HttpHeaderRange)
total := int64(len(data))
if rangeHdr == "" {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", total))
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(data)
return
}
// Parse bytes=start-end or bytes=start-
if !strings.HasPrefix(rangeHdr, "bytes=") {
w.WriteHeader(gohttp.StatusBadRequest)
return
}
spec := strings.TrimPrefix(rangeHdr, "bytes=")
parts := strings.Split(spec, "-")
start, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || start < 0 || start >= total {
w.WriteHeader(gohttp.StatusRequestedRangeNotSatisfiable)
return
}
end := total - 1
if len(parts) > 1 && parts[1] != "" {
if parsedEnd, err := strconv.ParseInt(parts[1], 10, 64); err == nil && parsedEnd >= start && parsedEnd < total {
end = parsedEnd
}
}
contentLength := end - start + 1
w.Header().Set(base.HttpHeaderContentRange, fmt.Sprintf("bytes %d-%d/%d", start, end, total))
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", contentLength))
w.WriteHeader(gohttp.StatusPartialContent)
_, _ = w.Write(data[start : end+1])
}
func TestFetcher_ChecksumMismatch_Retry(t *testing.T) {
corruptContent := []byte("Corrupt Initial Download Data 33B")
correctContent := []byte("Correct Retried Download Data 33B")
correctSha256Hasher := sha256.New()
correctSha256Hasher.Write(correctContent)
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
var phase atomic.Int32 // 0: serve corruptContent, 1: serve correctContent
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
var served []byte
if phase.Load() == 0 {
served = corruptContent
} else {
served = correctContent
}
serveRangeContent(w, r, served)
}))
defer server.Close()
downloadDir := t.TempDir()
fileName := "test_checksum_retry.txt"
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: downloadDir,
Name: fileName,
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: correctSha256,
},
},
}
// 1. Initial attempt -> will download corruptContent, failing checksum verification
if err := f.Resolve(&base.Request{URL: server.URL + "/retry_test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
err := f.Wait()
if err == nil {
t.Fatal("expected checksum mismatch error on initial attempt, got nil")
}
if !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
}
if f.getState() != stateError {
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
}
// Flip phase to 1 after observing checksum mismatch
phase.Store(1)
// 2. Retry download -> should trigger forced full reset, truncate file, and re-download correctContent
if err := f.Start(); err != nil {
t.Fatalf("Start error on retry: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error on retry (expected success): %v", err)
}
if f.getState() != stateDone {
t.Fatalf("expected stateDone after successful retry, got %v", f.getState())
}
// 3. Verify on-disk file content matches the re-downloaded bytes
filePath := f.meta.SingleFilepath()
fileBytes, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read completed file: %v", err)
}
if string(fileBytes) != string(correctContent) {
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
}
// 4. Compute hash independently and verify
actualHasher := sha256.New()
actualHasher.Write(fileBytes)
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
if actualHash != correctSha256 {
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
}
}
func TestFetcher_ChecksumMismatch_Retry_PartialPrefetch(t *testing.T) {
corruptContent := []byte("Corrupt Initial Download Data 33B")
correctContent := []byte("Correct Retried Download Data 33B")
correctSha256Hasher := sha256.New()
correctSha256Hasher.Write(correctContent)
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
var phase atomic.Int32 // 0: corrupt, 1: correct
var isFirstRequest atomic.Bool
isFirstRequest.Store(true)
releasePrefetch := make(chan struct{})
var closeReleaseOnce sync.Once
var mu sync.Mutex
var phase1Ranges []string
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
curPhase := phase.Load()
if curPhase == 1 {
mu.Lock()
phase1Ranges = append(phase1Ranges, r.Header.Get(base.HttpHeaderRange))
mu.Unlock()
serveRangeContent(w, r, correctContent)
return
}
// Phase 0: First request is Resolve GET without Range header.
// Write exactly 7 bytes and flush, then block until release channel closes or context is done.
if isFirstRequest.CompareAndSwap(true, false) {
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(corruptContent)))
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(corruptContent[:7])
if flusher, ok := w.(gohttp.Flusher); ok {
flusher.Flush()
}
select {
case <-releasePrefetch:
case <-r.Context().Done():
}
return
}
// Subsequent requests in Phase 0 (such as Range request from Start)
serveRangeContent(w, r, corruptContent)
}))
defer server.Close()
defer closeReleaseOnce.Do(func() { close(releasePrefetch) })
downloadDir := t.TempDir()
fileName := "test_checksum_prefetch_retry.txt"
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: downloadDir,
Name: fileName,
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: correctSha256,
},
},
}
// 1. Resolve phase
if err := f.Resolve(&base.Request{URL: server.URL + "/prefetch_retry.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
// Wait until prefetch has received exactly 7 bytes
for i := 0; i < 200; i++ {
if f.prefetchSize.Load() >= 7 {
break
}
time.Sleep(10 * time.Millisecond)
}
if f.prefetchSize.Load() < 7 {
t.Fatalf("expected at least 7 prefetched bytes, got %d", f.prefetchSize.Load())
}
// Release prefetch handler right before Start
closeReleaseOnce.Do(func() { close(releasePrefetch) })
// 2. Start initial download attempt (will complete with corruptContent and fail checksum)
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
err := f.Wait()
if err == nil {
t.Fatal("expected checksum mismatch error on initial attempt, got nil")
}
if !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
}
if f.getState() != stateError {
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
}
// 3. Flip to phase 1 (correctContent) and retry
phase.Store(1)
if err := f.Start(); err != nil {
t.Fatalf("Start error on retry: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error on retry (expected success): %v", err)
}
if f.getState() != stateDone {
t.Fatalf("expected stateDone after successful retry, got %v", f.getState())
}
// Verify that in Phase 1 at least one Range request started at byte 0
mu.Lock()
rangesSeen := append([]string(nil), phase1Ranges...)
mu.Unlock()
hasByteZero := false
for _, r := range rangesSeen {
if strings.HasPrefix(r, "bytes=0-") {
hasByteZero = true
break
}
}
if !hasByteZero {
t.Fatalf("expected at least one Phase 1 Range request starting at byte 0, saw: %v", rangesSeen)
}
// Verify on-disk content matches correctContent exactly
filePath := f.meta.SingleFilepath()
fileBytes, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read completed file: %v", err)
}
if string(fileBytes) != string(correctContent) {
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
}
// Compute hash independently and verify
actualHasher := sha256.New()
actualHasher.Write(fileBytes)
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
if actualHash != correctSha256 {
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
}
}
func TestFetcher_ChecksumMismatch_Retry_NonRange(t *testing.T) {
corruptContent := []byte("Corrupt Non-Range Initial Data 33B")
correctContent := []byte("Correct Non-Range Retried Data 33B")
correctSha256Hasher := sha256.New()
correctSha256Hasher.Write(correctContent)
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
var phase atomic.Int32 // 0: corrupt, 1: correct
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
// Server does not support Range (always returns full content 200 OK)
var served []byte
if phase.Load() == 0 {
served = corruptContent
} else {
served = correctContent
}
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(served)))
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(served)
}))
defer server.Close()
downloadDir := t.TempDir()
fileName := "test_checksum_nonrange_retry.txt"
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: downloadDir,
Name: fileName,
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: correctSha256,
},
},
}
// 1. Resolve and start initial attempt
if err := f.Resolve(&base.Request{URL: server.URL + "/nonrange_retry.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
err := f.Wait()
if err == nil {
t.Fatal("expected checksum mismatch error on initial non-range attempt, got nil")
}
if !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
}
if f.getState() != stateError {
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
}
// 2. Switch phase and retry
phase.Store(1)
if err := f.Start(); err != nil {
t.Fatalf("Start error on non-range retry: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error on non-range retry (expected success): %v", err)
}
if f.getState() != stateDone {
t.Fatalf("expected stateDone after successful non-range retry, got %v", f.getState())
}
// 3. Verify on-disk file content
filePath := f.meta.SingleFilepath()
fileBytes, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read completed file: %v", err)
}
if string(fileBytes) != string(correctContent) {
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
}
// 4. Compute hash independently and verify
actualHasher := sha256.New()
actualHasher.Write(fileBytes)
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
if actualHash != correctSha256 {
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
}
}
@@ -0,0 +1,304 @@
package http
import (
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"fmt"
gohttp "net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
)
func TestFetcher_ChecksumVerification(t *testing.T) {
content := []byte("Hello, Gopeed Checksum Verification Test Data!")
md5Hasher := md5.New()
md5Hasher.Write(content)
correctMd5 := hex.EncodeToString(md5Hasher.Sum(nil))
sha256Hasher := sha256.New()
sha256Hasher.Write(content)
correctSha256 := hex.EncodeToString(sha256Hasher.Sum(nil))
wrongHash := "0123456789abcdef0123456789abcdef"
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(content)))
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(content)
}))
defer server.Close()
t.Run("Valid MD5", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_valid_md5.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "md5",
Expected: correctMd5,
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected success): %v", err)
}
})
t.Run("Valid SHA256", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_valid_sha256.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: correctSha256,
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected success): %v", err)
}
})
t.Run("Wrong MD5 mismatch", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_wrong_md5.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "md5",
Expected: wrongHash,
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
err := f.Wait()
if err == nil {
t.Fatal("Wait expected checksum mismatch error, got nil")
}
if !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
}
})
t.Run("Unsupported Algorithm", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_unsupported.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "invalid-algo",
Expected: wrongHash,
},
},
}
err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts)
if err == nil {
t.Fatal("Resolve expected error for unsupported algorithm, got nil")
}
if !strings.Contains(err.Error(), "unsupported checksum algorithm") {
t.Fatalf("expected 'unsupported checksum algorithm' in error, got %v", err)
}
if f.checksumFailed.Load() {
t.Fatal("checksumFailed flag should NOT be set on unsupported algorithm error")
}
})
t.Run("Case-Insensitive Hash Comparison", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_case_insensitive.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: strings.ToUpper(correctSha256),
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected success with upper case hash): %v", err)
}
})
t.Run("Empty Expected Checksum (Skipped)", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_empty_expected.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "",
Expected: "",
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected skip and success): %v", err)
}
})
t.Run("Large File Multi-Chunk Streaming (>64KB)", func(t *testing.T) {
largeData := make([]byte, 128*1024)
for i := range largeData {
largeData[i] = byte(i % 256)
}
largeSha256Hasher := sha256.New()
largeSha256Hasher.Write(largeData)
largeSha256 := hex.EncodeToString(largeSha256Hasher.Sum(nil))
largeServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(largeData)))
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(largeData)
}))
defer largeServer.Close()
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_large_file.bin",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: largeSha256,
},
},
}
if err := f.Resolve(&base.Request{URL: largeServer.URL + "/large.bin"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected large file checksum success): %v", err)
}
})
t.Run("No Checksum (Opt-in backward compatibility)", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: test.DownloadName,
Extra: &http.OptsExtra{
Connections: 1,
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error: %v", err)
}
})
t.Run("Invalid Checksum In Resolve Makes Zero Requests", func(t *testing.T) {
var reqReceived atomic.Int32
customServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
reqReceived.Add(1)
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write([]byte("some data"))
}))
defer customServer.Close()
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_invalid_checksum.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: "invalid-hex-chars-not-valid-hex-00000000000000000000000000000000",
},
},
}
err := f.Resolve(&base.Request{URL: customServer.URL + "/test.txt"}, opts)
if err == nil {
t.Fatal("Resolve expected error for invalid checksum hash, got nil")
}
if reqReceived.Load() != 0 {
t.Fatalf("expected 0 requests to server on invalid checksum in Resolve, got %d", reqReceived.Load())
}
})
}
+13
View File
@@ -13,6 +13,7 @@ import (
"net/url" "net/url"
"os" "os"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@@ -853,22 +854,34 @@ func TestFetcher_InitialIgnoredRangeUsesResponseSize(t *testing.T) {
resolvePayload := bytes.Repeat([]byte("resolve"), 64*1024) resolvePayload := bytes.Repeat([]byte("resolve"), 64*1024)
replacement := bytes.Repeat([]byte("replacement"), 24*1024) replacement := bytes.Repeat([]byte("replacement"), 24*1024)
var rangeRequests atomic.Int32 var rangeRequests atomic.Int32
rangeStarted := make(chan struct{})
releaseResolve := sync.OnceFunc(func() { close(rangeStarted) })
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes) w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
if r.Header.Get(base.HttpHeaderRange) == "" { if r.Header.Get(base.HttpHeaderRange) == "" {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(resolvePayload))) w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(resolvePayload)))
w.WriteHeader(gohttp.StatusOK) w.WriteHeader(gohttp.StatusOK)
w.(gohttp.Flusher).Flush()
// Keep Resolve prefetch incomplete until the Range request starts.
// Otherwise Start may reuse the completed body and skip this scenario.
select {
case <-rangeStarted:
case <-r.Context().Done():
return
}
_, _ = w.Write(resolvePayload) _, _ = w.Write(resolvePayload)
return return
} }
rangeRequests.Add(1) rangeRequests.Add(1)
releaseResolve()
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(replacement))) w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(replacement)))
w.WriteHeader(gohttp.StatusOK) w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(replacement) _, _ = w.Write(replacement)
})) }))
defer server.Close() defer server.Close()
defer releaseResolve()
f := buildFetcher() f := buildFetcher()
if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}, &base.Options{ if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}, &base.Options{
@@ -0,0 +1,79 @@
package http
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
)
func TestFetcher_RestoredStartSkipsAbsentPrefetch(t *testing.T) {
data := bytes.Repeat([]byte("0123456789abcdef"), 64*1024)
var firstRequest atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
firstRequest.CompareAndSwap(0, time.Now().UnixNano())
http.ServeContent(w, r, "restored.bin", time.Time{}, bytes.NewReader(data))
}))
defer server.Close()
dir := t.TempDir()
size := int64(len(data))
half := size / 2
file := filepath.Join(dir, "restored.bin")
if err := os.WriteFile(file, data[:half], 0600); err != nil {
t.Fatal(err)
}
conn := &connection{ID: 0, Role: rolePrimary, State: connNotStarted, Chunk: newChunk(0, size-1), Downloaded: half}
conn.Chunk.Downloaded = half
rangeMode := true
saved := &fetcherData{Connections: []*connection{conn}, Range: &rangeMode, ResourceSize: &size, FileSize: &size}
meta := &fetcher.FetcherMeta{
Req: &base.Request{URL: server.URL + "/restored.bin"},
Res: &base.Resource{Size: size, Range: true, Files: []*base.FileInfo{{Name: "restored.bin", Size: size}}},
Opts: &base.Options{Path: dir, Name: "restored.bin", Extra: &fhttp.OptsExtra{Connections: 1}},
}
fm := new(FetcherManager)
_, restore := fm.Restore()
f := restore(meta, saved).(*Fetcher)
ctl := controller.NewController()
ctl.GetConfig = func(v any) {
if err := json.Unmarshal([]byte(test.ToJson(fm.DefaultConfig())), v); err != nil {
t.Error(err)
}
}
f.Setup(ctl)
defer f.Close()
started := time.Now()
if err := f.Start(); err != nil {
t.Fatal(err)
}
if err := f.Wait(); err != nil {
t.Fatal(err)
}
actual, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(actual, data) {
t.Fatal("restored download differs from the complete server content")
}
if firstRequest.Load() == 0 {
t.Fatal("restored download did not request the remaining content")
}
delay := time.Duration(firstRequest.Load() - started.UnixNano())
t.Logf("first restored request after %s", delay)
if delay > 2*time.Second {
t.Errorf("restored download waited %s before its first request", delay)
}
}
+1
View File
@@ -1912,6 +1912,7 @@ func (d *Downloader) doPause(task *Task) (err error) {
} }
if handled { if handled {
d.emit(EventKeyPause, task) d.emit(EventKeyPause, task)
d.triggerWebhooks(WebhookEventDownloadPause, task, nil)
} }
return nil return nil
} }
@@ -0,0 +1,222 @@
package engine_test
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/GopeedLab/gopeed/pkg/download/engine"
)
// Exercise server-side header permissions, including formerly forbidden names,
// download authentication, metadata and non-safelisted no-cors values.
var extensionRequestHeaders = [][2]string{
{"Accept-Charset", "utf-8"}, {"Accept-Encoding", "identity"},
{"Access-Control-Request-Headers", "authorization, x-download"},
{"Access-Control-Request-Method", "POST"}, {"Connection", "close"},
{"Connection", "keep-alive"},
{"Content-Length", "4"}, {"Cookie", "sid=manual"}, {"Cookie2", "legacy=1"},
{"Date", "Fri, 09 Oct 2026 00:00:00 GMT"}, {"DNT", "1"},
{"Host", "download.example:1234"}, {"Origin", "https://download.example"},
{"Permissions-Policy", "camera=()"}, {"Referer", "https://download.example/file"},
{"TE", "trailers"}, {"Trailer", "X-Checksum"}, {"Set-Cookie", "sid=request"},
{"Set-Cookie2", "legacy=request"}, {"Via", "1.1 proxy"},
{"Proxy-Authorization", "Basic dGVzdA=="}, {"Proxy-", "custom"},
{"Sec-Fetch-Site", "same-origin"}, {"Sec-Fetch-Mode", "custom"},
{"Sec-CH-UA", `"Download client"`}, {"Sec-", "custom"},
{"X-HTTP-Method", "TRACE"}, {"X-HTTP-Method-Override", "CONNECT"},
{"X-Method-Override", "TRACK"}, {"Authorization", "Bearer token"},
{"User-Agent", "Gopeed extension"}, {"Content-Type", "application/json"},
{"Range", "bytes=0-1,3-4"}, {"X-Download-Token", "custom"},
{"Accept-Language", strings.Repeat("x", 129)},
}
func TestExtensionRequestsSendNodeHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
body, err := io.ReadAll(request.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := request.URL.Query().Get("header")
value := request.Header.Get(name)
if name == "Host" {
value = request.Host
}
_ = json.NewEncoder(w).Encode([]string{value, string(body)})
}))
t.Cleanup(server.Close)
clients := []struct{ name, script string }{
{"fetch record", `const response = await fetch(url, {method: "POST", body: "test", headers: {[name]: value}}); return (await response.text()).trim();`},
{"fetch no-cors Headers", `const response = await fetch(url, {method: "POST", body: "test", mode: "no-cors", headers: new Headers([[name.toLowerCase(), value]])}); return (await response.text()).trim();`},
{"fetch Request clone", `const request = new Request(url, {method: "POST", body: "test", mode: "same-origin", headers: [[name.toUpperCase(), value]]}); const response = await fetch(request.clone()); return (await response.text()).trim();`},
}
for _, credentials := range []bool{false, true} {
clients = append(clients, struct{ name, script string }{
fmt.Sprintf("XHR credentials=%t", credentials),
fmt.Sprintf(`return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.withCredentials = %t;
xhr.setRequestHeader(name.toLowerCase(), value);
xhr.onload = () => resolve(xhr.responseText.trim());
xhr.onerror = () => reject(new Error("XHR failed"));
xhr.send("test");
});`, credentials),
})
}
for _, header := range extensionRequestHeaders {
for _, client := range clients {
t.Run(header[0]+"/"+client.name, func(t *testing.T) {
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const name = %q, value = %q;
const url = %q + "?header=" + encodeURIComponent(name);
%s
})()`, header[0], header[1], server.URL, client.script))
if err != nil {
t.Fatal(err)
}
want, _ := json.Marshal([]string{header[1], "test"})
if value != string(want) {
t.Fatalf("server received %#v, want %s", value, want)
}
})
}
}
}
func TestExtensionHeaderMutationsMatchNode(t *testing.T) {
headers := append([][2]string(nil), extensionRequestHeaders...)
// Node stores these in Headers/Request, but rejects them during fetch.
headers = append(headers, [2]string{"Expect", "100-continue"}, [2]string{"Keep-Alive", "timeout=5"}, [2]string{"Transfer-Encoding", "chunked"}, [2]string{"Upgrade", "websocket"})
data, _ := json.Marshal(headers)
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
_, err := runtime.RunString(fmt.Sprintf(`(() => {
for (const mode of ["cors", "same-origin", "no-cors"]) {
for (const [name, value] of %s) {
const request = new Request("http://example.com", {mode, headers: {[name]: value}});
const headers = request.headers;
if (headers.get(name) !== value) throw new Error("Filtered init " + name + " in " + mode);
headers.delete(name.toUpperCase());
if (headers.has(name)) throw new Error("Cannot delete " + name);
headers.append(name, value);
if (headers.get(name) !== value) throw new Error("Cannot append " + name);
headers.set(name, value);
if (request.clone().headers.get(name) !== value) throw new Error("Cannot clone " + name);
}
}
return true;
})()`, data))
if err != nil {
t.Fatal(err)
}
}
func TestExtensionRequestsIgnoreUnsupportedTransportHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
body, err := io.ReadAll(request.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
value := request.Header.Get(request.URL.Query().Get("header"))
_ = json.NewEncoder(w).Encode([]string{value, string(body)})
}))
t.Cleanup(server.Close)
for _, header := range [][2]string{
{"Expect", "100-continue"}, {"Keep-Alive", "timeout=5"},
{"Transfer-Encoding", "chunked"}, {"Upgrade", "websocket"},
{"Connection", "upgrade"}, {"Content-Length", "3"},
{"Content-Length", "5"}, {"Content-Length", "invalid"},
{"Content-Length", "-1"}, {"Content-Length", ""},
} {
for _, xhr := range []bool{false, true} {
t.Run(fmt.Sprintf("%s=%s/XHR=%t", header[0], header[1], xhr), func(t *testing.T) {
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const name = %q, value = %q;
const url = %q + "?header=" + encodeURIComponent(name);
if (%t) return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.setRequestHeader(name, value);
xhr.onload = () => resolve(xhr.responseText.trim());
xhr.onerror = () => reject(new Error("XHR failed"));
xhr.send("test");
});
const response = await fetch(url, {method: "POST", body: "test", headers: {[name]: value}});
return (await response.text()).trim();
})()`, header[0], header[1], server.URL, xhr))
if err != nil {
t.Fatal(err)
}
wireValue := ""
if header[0] == "Content-Length" {
wireValue = "4"
}
want, _ := json.Marshal([]string{wireValue, "test"})
if value != string(want) {
t.Fatalf("server received %#v, want %s", value, want)
}
})
}
}
}
func TestExtensionResponseExposesSetCookie(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Add("Set-Cookie", "sid=one; HttpOnly")
w.Header().Add("Set-Cookie", "token=two")
w.Header().Set("Set-Cookie2", "legacy=three")
_, _ = w.Write([]byte("ok"))
}))
t.Cleanup(server.Close)
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const response = await fetch(%q);
const clone = response.clone();
await response.text(); await clone.text();
let immutable = false;
try { response.headers.set("Set-Cookie", "changed=1"); } catch (error) { immutable = error instanceof TypeError; }
const synthetic = new Response(null, {headers: {"Set-Cookie": "initial=1"}});
synthetic.headers.append("Set-Cookie", "appended=2");
return JSON.stringify({cookies: clone.headers.getSetCookie(), legacy: response.headers.get("set-cookie2"), immutable, synthetic: synthetic.headers.getSetCookie()});
})()`, server.URL))
if err != nil {
t.Fatal(err)
}
const want = `{"cookies":["sid=one; HttpOnly","token=two"],"legacy":"legacy=three","immutable":true,"synthetic":["initial=1","appended=2"]}`
if value != want {
t.Fatalf("unexpected response headers: got %#v want %s", value, want)
}
}
func TestExtensionHeadersRejectInvalidSyntax(t *testing.T) {
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
_, err := runtime.RunString(`(() => {
for (const [name, value] of [["Bad Header", "ok"], ["", "ok"], ["X:Header", "ok"],
["X-Test", "a\nb"], ["X-Test", "a\rb"], ["X-Test", "\0"], ["X-Test", "\u0100"]]) {
for (const create of [() => new Headers([[name, value]]),
() => new Request("http://example.com", {mode: "no-cors", headers: {[name]: value}}),
() => { const xhr = new XMLHttpRequest(); xhr.open("GET", "http://example.com"); xhr.setRequestHeader(name, value); }]) {
let rejected = false;
try { create(); } catch (error) { rejected = error instanceof TypeError; }
if (!rejected) throw new Error("Accepted invalid HTTP header syntax");
}
}
return true;
})()`)
if err != nil {
t.Fatal(err)
}
}
@@ -105,6 +105,105 @@ func TestXMLHttpRequestUsesFetch(t *testing.T) {
} }
} }
func TestExtensionRequestsSendExplicitCookie(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
_, _ = w.Write([]byte(request.Header.Get("Cookie")))
}))
t.Cleanup(server.Close)
type requestTest struct {
name string
script string
}
tests := []requestTest{
{"fetch record", `const response = await fetch(url, {headers: {Cookie: cookie}}); return response.text();`},
{"fetch Headers", `const response = await fetch(url, {headers: new Headers({cookie})}); return response.text();`},
{"fetch pairs", `const response = await fetch(url, {headers: [["cOoKiE", cookie]]}); return response.text();`},
{"fetch repeated Cookie", `const headers = new Headers([["Cookie", "sid=manual"], ["cookie", "token=provided"]]); if (headers.get("cookie") !== cookie) throw new Error("Incorrect Cookie separator"); const response = await fetch(url, {headers}); return response.text();`},
{"fetch Request clone", `const request = new Request(url, {headers: {Cookie: cookie}}); const response = await fetch(request.clone()); return response.text();`},
{"fetch Request set", `const request = new Request(url); request.headers.set("Cookie", cookie); const response = await fetch(request); return response.text();`},
{"fetch Request append", `const request = new Request(url); request.headers.append("Cookie", cookie); const response = await fetch(request); return response.text();`},
{"fetch no-cors mutations", `const request = new Request(url, {mode: "no-cors"}); request.headers.set("Cookie", "removed=1"); request.headers.delete("COOKIE"); if (request.headers.has("cookie")) throw new Error("Cookie was not deleted"); request.headers.append("Cookie", cookie); const response = await fetch(request.clone()); return response.text();`},
}
for _, mode := range []string{"cors", "same-origin", "no-cors"} {
for _, credentials := range []string{"omit", "same-origin", "include"} {
tests = append(tests, requestTest{
name: "fetch " + mode + " " + credentials,
script: fmt.Sprintf(`const response = await fetch(url, {headers: {Cookie: cookie}, mode: %q, credentials: %q}); return response.text();`, mode, credentials),
})
}
}
for _, withCredentials := range []bool{false, true} {
tests = append(tests, requestTest{
name: fmt.Sprintf("XHR withCredentials=%t", withCredentials),
script: fmt.Sprintf(`return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.withCredentials = %t;
xhr.setRequestHeader("cOoKiE", cookie);
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(new Error("XHR failed"));
xhr.send();
});`, withCredentials),
})
}
tests = append(tests, requestTest{"XHR repeated Cookie", `return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.setRequestHeader("Cookie", "sid=manual");
xhr.setRequestHeader("cookie", "token=provided");
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(new Error("XHR failed"));
xhr.send();
});`})
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
const cookie = "sid=manual; token=provided"
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const url = %q;
const cookie = %q;
%s
})()`, server.URL, cookie, test.script))
if err != nil {
t.Fatal(err)
}
if value != cookie {
t.Fatalf("server received Cookie %q, want %q", value, cookie)
}
})
}
}
func TestFetchExplicitCookieWithCookieJar(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/seed" {
http.SetCookie(w, &http.Cookie{Name: "stored", Value: "jar", Path: "/"})
}
_, _ = w.Write([]byte(request.Header.Get("Cookie")))
}))
t.Cleanup(server.Close)
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const url = %q;
await (await fetch(url + "/seed")).text();
const automatic = await (await fetch(url)).text();
const combined = await (await fetch(url, {headers: {Cookie: "manual=provided"}})).text();
const omitted = await (await fetch(url, {credentials: "omit", headers: {Cookie: "manual=provided"}})).text();
const empty = await (await fetch(url, {credentials: "omit"})).text();
return JSON.stringify({automatic, combined, omitted, empty});
})()`, server.URL))
if err != nil {
t.Fatal(err)
}
const want = `{"automatic":"stored=jar","combined":"manual=provided; stored=jar","omitted":"manual=provided","empty":""}`
if value != want {
t.Fatalf("unexpected CookieJar result: got %#v want %s", value, want)
}
}
func TestFetchResponseAndRequestCloneHaveIndependentBodies(t *testing.T) { func TestFetchResponseAndRequestCloneHaveIndependentBodies(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodPost { if request.Method == http.MethodPost {
+11 -56
View File
@@ -122,7 +122,7 @@
if (name === "set-cookie") { if (name === "set-cookie") {
for (const value of values) result.push([name, value]); for (const value of values) result.push([name, value]);
} else { } else {
result.push([name, values.join(", ")]); result.push([name, values.join(name === "cookie" ? "; " : ", ")]);
} }
} }
return result; return result;
@@ -158,49 +158,10 @@
return iterator; return iterator;
} }
function forbiddenRequestHeader(name, value) { function assertMutableHeaders(headers) {
if (name.startsWith("proxy-") || name.startsWith("sec-")) return true; // Match Node/Undici: HTTP syntax and immutability still apply, but browser
if ([ // forbidden-header and no-cors safelist rules do not apply to extensions.
"accept-charset", "accept-encoding", "access-control-request-headers",
"access-control-request-method", "connection", "content-length", "cookie",
"cookie2", "date", "dnt", "expect", "host", "keep-alive", "origin",
"permissions-policy", "referer", "te", "trailer", "transfer-encoding",
"set-cookie", "upgrade", "via",
].includes(name)) return true;
if (["x-http-method", "x-http-method-override", "x-method-override"].includes(name)) {
return String(value).split(",").some((method) => {
method = method.trim().toUpperCase();
return method === "CONNECT" || method === "TRACE" || method === "TRACK";
});
}
return false;
}
function noCORSSafelistedRequestHeader(name, value) {
if (value.length > 128) return false;
const hasUnsafeByte = /[\x00-\x08\x0A-\x1F\x7F"():<>?@\[\\\]{}]/.test(value);
if (name === "accept") return !hasUnsafeByte;
if (name === "accept-language" || name === "content-language") {
return /^[0-9A-Za-z *,\-.;=]*$/.test(value);
}
if (name === "content-type") {
if (hasUnsafeByte) return false;
const essence = value.split(";", 1)[0].trim().toLowerCase();
return essence === "application/x-www-form-urlencoded" ||
essence === "multipart/form-data" || essence === "text/plain";
}
if (name === "range") return /^bytes=[0-9]+-[0-9]*$/.test(value);
return false;
}
function blockedByGuard(headers, name, value) {
if (headers._guard === "immutable") throw new TypeError("Headers are immutable"); if (headers._guard === "immutable") throw new TypeError("Headers are immutable");
if (headers._guard === "request") return forbiddenRequestHeader(name, value);
if (headers._guard === "request-no-cors") {
return forbiddenRequestHeader(name, value) || !noCORSSafelistedRequestHeader(name, value);
}
if (headers._guard === "response") return name === "set-cookie" || name === "set-cookie2";
return false;
} }
class Headers { class Headers {
@@ -235,27 +196,21 @@
name = normalizeHeaderName(name); name = normalizeHeaderName(name);
value = normalizeHeaderValue(value); value = normalizeHeaderValue(value);
const values = this._values.get(name); const values = this._values.get(name);
const combinedValue = values && name !== "set-cookie" ? values.join(", ") + ", " + value : value; assertMutableHeaders(this);
if (blockedByGuard(this, name, combinedValue)) return;
if (values) values.push(value); if (values) values.push(value);
else this._values.set(name, [value]); else this._values.set(name, [value]);
} }
delete(name) { delete(name) {
name = normalizeHeaderName(name); name = normalizeHeaderName(name);
if (this._guard === "immutable") throw new TypeError("Headers are immutable"); assertMutableHeaders(this);
if (this._guard === "request-no-cors") {
if (forbiddenRequestHeader(name, "") || !["accept", "accept-language", "content-language", "content-type", "range"].includes(name)) return;
this._values.delete(name);
return;
}
if (blockedByGuard(this, name, "")) return;
this._values.delete(name); this._values.delete(name);
} }
get(name) { get(name) {
const values = this._values.get(normalizeHeaderName(name)); name = normalizeHeaderName(name);
return values ? values.join(", ") : null; const values = this._values.get(name);
return values ? values.join(name === "cookie" ? "; " : ", ") : null;
} }
getSetCookie() { getSetCookie() {
@@ -270,7 +225,7 @@
set(name, value) { set(name, value) {
name = normalizeHeaderName(name); name = normalizeHeaderName(name);
value = normalizeHeaderValue(value); value = normalizeHeaderValue(value);
if (blockedByGuard(this, name, value)) return; assertMutableHeaders(this);
this._values.set(name, [value]); this._values.set(name, [value]);
} }
@@ -484,7 +439,7 @@
} }
this._duplex = "half"; this._duplex = "half";
const headerInit = init.headers !== undefined ? init.headers : (source ? source.headers : undefined); const headerInit = init.headers !== undefined ? init.headers : (source ? source.headers : undefined);
this._headers = new Headers(headerInit, this._mode === "no-cors" ? "request-no-cors" : "request"); this._headers = new Headers(headerInit, "request");
let body = init.body !== undefined ? init.body : (source ? source._bodyInit : null); let body = init.body !== undefined ? init.body : (source ? source._bodyInit : null);
if (source && !bodyWasOverridden && source.body) body = transferReadableStream(source.body); if (source && !bodyWasOverridden && source.body) body = transferReadableStream(source.body);
const bodyIsStream = typeof ReadableStream !== "undefined" && body instanceof ReadableStream; const bodyIsStream = typeof ReadableStream !== "undefined" && body instanceof ReadableStream;
@@ -302,6 +302,23 @@ func (r *registry) Open(fingerprint string, proxyHandler func(*http.Request) (*u
return nil, err return nil, err
} }
for _, header := range metadata.Headers { for _, header := range metadata.Headers {
// Ignore transport controls unsupported by Node/Undici rather than
// applying a browser forbidden-header list or failing the whole request.
switch strings.ToLower(header[0]) {
case "expect", "keep-alive", "transfer-encoding", "upgrade", "content-length":
// Content-Length is generated from the buffered request body below.
continue
case "connection":
if value := strings.ToLower(header[1]); value != "close" && value != "keep-alive" {
continue
}
case "trailer":
// Fetch supplies a declaration, not a Go Request.Trailer payload.
// Keep its lower-case wire name so Go's writer does not omit it as
// the canonical Trailer field it normally generates itself.
httpRequest.Header["trailer"] = append(httpRequest.Header["trailer"], header[1])
continue
}
httpRequest.Header.Add(header[0], header[1]) httpRequest.Header.Add(header[0], header[1])
} }
if host := httpRequest.Header.Get("Host"); host != "" { if host := httpRequest.Header.Get("Host"); host != "" {
+26 -2
View File
@@ -9,7 +9,7 @@ The files under `fetch/` are copied from the official
Gopeed executes applicable worker-style Fetch API tests at the extension Gopeed executes applicable worker-style Fetch API tests at the extension
JavaScript boundary. The checked-in runner currently executes unchanged WPT JavaScript boundary. The checked-in runner currently executes unchanged WPT
tests for Headers, no-CORS header guards, Request construction and body tests for Headers syntax and iteration, Request construction and body
consumption, ReadableStream bodies, keepalive, Request errors, Response consumption, ReadableStream bodies, keepalive, Request errors, Response
construction and body consumption, immutable network response headers, and construction and body consumption, immutable network response headers, and
the Response static constructors. the Response static constructors.
@@ -19,10 +19,34 @@ a claim that Gopeed is a browser. Browser-only suites that require a document,
CORS origin enforcement or preflight, CSP, Mixed Content, Service Workers, CORS origin enforcement or preflight, CSP, Mixed Content, Service Workers,
browser HTTP cache, navigation, browser authentication UI, or WPT's browser HTTP cache, navigation, browser authentication UI, or WPT's
multi-origin server infrastructure are intentionally excluded. Gopeed also multi-origin server infrastructure are intentionally excluded. Gopeed also
keeps one extension-specific behavior: `redirect: "manual"` exposes the HTTP keeps extension-specific behavior: `redirect: "manual"` exposes the HTTP
redirect response so download extensions can inspect `Location`, instead of redirect response so download extensions can inspect `Location`, instead of
returning a browser `opaqueredirect` response. returning a browser `opaqueredirect` response.
Header permissions follow [Node/Undici's server-side Fetch API](https://github.com/nodejs/undici/blob/main/README.md#forbidden-and-safelisted-header-names). Gopeed does not
apply browser forbidden request/response header lists or no-CORS safelists.
Extensions may supply authentication, referrer, origin, host, proxy and fetch
metadata headers, and read `Set-Cookie` response headers. Network response
headers remain immutable; invalid header syntax still raises an error.
Repeated `Cookie` values are combined with `; `, as in Node/Undici.
The runner excludes the browser-only `headers-no-cors.any.js` and
`headers-forbidden-override.any.js` suites, the filtering assertions within
`request-headers.any.js`, and the forbidden-response-cookie assertion within
`header-setcookie.any.js`. Gopeed's `extension_headers_test.go` replaces those
restrictions with integration tests that check headers actually received by
an HTTP server through fetch and XMLHttpRequest, plus header mutations,
cloning and response cookies. The upstream test files remain unchanged.
Gopeed deliberately ignores transport controls that Node/Undici rejects:
`Expect`, `Keep-Alive`, `Transfer-Encoding`, `Upgrade`, and `Connection` values
other than `close` or `keep-alive`. `Content-Length` is generated from the
actual request body, ignoring a supplied length. Explicit `Host` and
`Sec-Fetch-Mode` values are preserved rather than replaced by defaults.
These transport behaviors are also covered by integration tests.
`credentials: "omit"` disables automatic CookieJar cookies but preserves
explicit headers; Gopeed retains its extension CookieJar support.
`wpt_harness.js` is a small Goja adapter maintained by Gopeed; it is not copied `wpt_harness.js` is a small Goja adapter maintained by Gopeed; it is not copied
from WPT. Test logic below `fetch/` is unchanged; the checked-in copy may only from WPT. Test logic below `fetch/` is unchanged; the checked-in copy may only
normalize a final newline. normalize a final newline.
+25 -17
View File
@@ -20,9 +20,6 @@ var wptFetchUtils string
//go:embed testdata/wpt/fetch/api/request/request-error.js //go:embed testdata/wpt/fetch/api/request/request-error.js
var wptRequestError string var wptRequestError string
//go:embed testdata/wpt/fetch/api/cors/resources/not-cors-safelisted.json
var wptNoCORSSafelistedHeaders []byte
//go:embed testdata/wpt/fetch/api/headers/*.any.js //go:embed testdata/wpt/fetch/api/headers/*.any.js
var wptHeaders embed.FS var wptHeaders embed.FS
@@ -30,31 +27,19 @@ var wptHeaders embed.FS
var wptRequestResponse embed.FS var wptRequestResponse embed.FS
func TestWPTHeaders(t *testing.T) { func TestWPTHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(wptNoCORSSafelistedHeaders)
}))
t.Cleanup(server.Close)
tests := []string{ tests := []string{
"header-setcookie.any.js", "header-setcookie.any.js",
"headers-basic.any.js", "headers-basic.any.js",
"headers-casing.any.js", "headers-casing.any.js",
"headers-combine.any.js", "headers-combine.any.js",
"headers-errors.any.js", "headers-errors.any.js",
"headers-forbidden-override.any.js",
"headers-normalize.any.js", "headers-normalize.any.js",
"headers-no-cors.any.js",
"headers-record.any.js", "headers-record.any.js",
"headers-structure.any.js", "headers-structure.any.js",
} }
for _, name := range tests { for _, name := range tests {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
setup := "" runWPTFile(t, wptHeaders, path.Join("testdata/wpt/fetch/api/headers", name), "")
if name == "headers-no-cors.any.js" {
setup = fmt.Sprintf("globalThis.location = new URL(%q);", server.URL+"/fetch/api/headers/test.any.js")
}
runWPTFile(t, wptHeaders, path.Join("testdata/wpt/fetch/api/headers", name), setup)
}) })
} }
} }
@@ -110,7 +95,30 @@ func runWPTFile(t *testing.T, files embed.FS, name, setup string) {
} }
runtime := engine.NewEngine(nil) runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close) t.Cleanup(runtime.Close)
value, err := runtime.RunString(setup + "\n" + wptHarness + "\n" + wptFetchUtils + "\n" + wptRequestError + "\n" + string(source) + "\n__wptFinish();") adaptation := ""
if name == "testdata/wpt/fetch/api/request/request-headers.any.js" {
// Node-style header permissions are covered by extension integration tests.
// Keep upstream tests unchanged and exclude browser-only restrictions.
adaptation = `
const upstreamTest = globalThis.test;
globalThis.test = function (callback, name) {
if (name.startsWith('Adding invalid request header ') ||
name.startsWith('Adding invalid no-cors request header ') ||
name.startsWith('Check that request constructor is filtering headers ') ||
name.startsWith('Check that no-cors request constructor is filtering headers ')) return;
upstreamTest(callback, name);
};
`
} else if name == "testdata/wpt/fetch/api/headers/header-setcookie.any.js" {
adaptation = `
const upstreamTest = globalThis.test;
globalThis.test = function (callback, name) {
if (name === 'Set-Cookie is a forbidden response header') return;
upstreamTest(callback, name);
};
`
}
value, err := runtime.RunString(setup + "\n" + wptHarness + "\n" + wptFetchUtils + "\n" + wptRequestError + "\n" + adaptation + "\n" + string(source) + "\n__wptFinish();")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+3
View File
@@ -23,6 +23,9 @@ type ResolveResult struct {
Res *base.Resource `json:"res"` Res *base.Resource `json:"res"`
} }
// Task represents a download unit.
// Options flow path:
// REST Request (model.CreateTask/ResolveTask) -> pkg/download Downloader -> Task.Meta.Opts (base.Options) -> Fetcher.Resolve/Start (e.g. internal/protocol/http/fetcher.go)
type Task struct { type Task struct {
ID string `json:"id"` ID string `json:"id"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
+1
View File
@@ -21,6 +21,7 @@ type WebhookEvent string
const ( const (
WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE" WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE"
WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR" WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
WebhookEventDownloadPause WebhookEvent = "DOWNLOAD_PAUSE"
) )
// WebhookData is the data sent to webhook URLs // WebhookData is the data sent to webhook URLs
+43
View File
@@ -814,6 +814,49 @@ func TestWebhook_TestWebhookUrl_VerifyTestPayload(t *testing.T) {
}) })
} }
func TestWebhook_TriggerOnPause(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{server.URL},
}
downloader.PutConfig(cfg)
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
downloader.triggerWebhooks(WebhookEventDownloadPause, task, nil)
select {
case data := <-receivedData:
if data.Event != WebhookEventDownloadPause {
t.Errorf("Expected event 'DOWNLOAD_PAUSE', got '%s'", data.Event)
}
if data.Payload == nil || data.Payload.Task == nil {
t.Error("Expected payload.task to be present")
} else if data.Payload.Task.ID != task.ID {
t.Errorf("Expected task ID '%s', got '%s'", task.ID, data.Payload.Task.ID)
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) { func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
defaultDownloader.Setup() defaultDownloader.Setup()
defaultDownloader.cfg.StorageDir = ".test_storage" defaultDownloader.cfg.StorageDir = ".test_storage"
+61 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download" "github.com/GopeedLab/gopeed/pkg/download"
phttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/google/jsonschema-go/jsonschema" "github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@@ -71,6 +72,7 @@ type httpTaskExtra struct {
AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"` AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"`
ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"` ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"`
DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"` DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"`
Checksum *checksumOption `json:"checksum,omitempty" jsonschema:"Optional checksum verification settings"`
} }
// downloadRequest is the MCP-facing form of base.Request. The REST model uses // downloadRequest is the MCP-facing form of base.Request. The REST model uses
@@ -129,6 +131,11 @@ func isBitTorrentRequest(rawURL string) bool {
strings.HasPrefix(upperURL, "DATA:APPLICATION/X-BITTORRENT;BASE64,") strings.HasPrefix(upperURL, "DATA:APPLICATION/X-BITTORRENT;BASE64,")
} }
type checksumOption struct {
Algorithm string `json:"algorithm" jsonschema:"Checksum algorithm: md5, sha1, or sha256"`
Expected string `json:"expected" jsonschema:"Expected hex-encoded checksum hash"`
}
// downloadOptions is the MCP-facing form of base.Options. Only HTTP currently // downloadOptions is the MCP-facing form of base.Options. Only HTTP currently
// defines protocol-specific task options, so Extra has one concrete schema. // defines protocol-specific task options, so Extra has one concrete schema.
type downloadOptions struct { type downloadOptions struct {
@@ -186,12 +193,31 @@ func (o *downloadOptions) baseOptions() *base.Options {
if o == nil { if o == nil {
return nil return nil
} }
var extra any
if o.Extra != nil {
var checksum *phttp.ChecksumOption
if o.Extra.Checksum != nil {
checksum = &phttp.ChecksumOption{
Algorithm: o.Extra.Checksum.Algorithm,
Expected: o.Extra.Checksum.Expected,
}
}
extra = &phttp.OptsExtra{
Connections: o.Extra.Connections,
AutoTorrent: o.Extra.AutoTorrent,
DeleteTorrentAfterDownload: o.Extra.DeleteTorrentAfterDownload,
AutoExtract: o.Extra.AutoExtract,
ArchivePassword: o.Extra.ArchivePassword,
DeleteAfterExtract: o.Extra.DeleteAfterExtract,
Checksum: checksum,
}
}
return &base.Options{ return &base.Options{
Name: o.Name, Name: o.Name,
Path: o.Path, Path: o.Path,
AsDefaultPath: o.AsDefaultPath, AsDefaultPath: o.AsDefaultPath,
SelectFiles: o.SelectFiles, SelectFiles: o.SelectFiles,
Extra: o.Extra, Extra: extra,
} }
} }
@@ -270,6 +296,13 @@ type getTaskStatsOutput struct {
Stats any `json:"stats"` Stats any `json:"stats"`
} }
type getDownloadSpeedInput struct{}
type getDownloadSpeedOutput struct {
TotalSpeed int64 `json:"totalSpeed"`
ActiveTaskCount int `json:"activeTaskCount"`
}
type taskActionOutput struct { type taskActionOutput struct {
ID string `json:"id"` ID string `json:"id"`
Success bool `json:"success"` Success bool `json:"success"`
@@ -416,6 +449,33 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) {
return nil, &getTaskStatsOutput{Stats: stats}, nil return nil, &getTaskStatsOutput{Stats: stats}, nil
}) })
mcp.AddTool(server, &mcp.Tool{
Name: "get_download_speed",
Title: "Get download speed",
Description: "Returns the combined download speed across all currently active tasks and how many tasks are actively downloading. Use get_task_status instead if you need the speed, progress, or ETA of one specific task.",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closedWorld},
}, func(_ context.Context, _ *mcp.CallToolRequest, _ *getDownloadSpeedInput) (*mcp.CallToolResult, *getDownloadSpeedOutput, error) {
allTasks := downloader.GetTasks()
var totalSpeed int64
activeCount := 0
for _, task := range allTasks {
status, err := downloader.RuntimeStatus(task.ID)
if err != nil {
continue
}
if status.Status == base.DownloadStatusRunning {
activeCount++
totalSpeed += status.Speed
}
}
return nil, &getDownloadSpeedOutput{
TotalSpeed: totalSpeed,
ActiveTaskCount: activeCount,
}, nil
})
registerTaskAction(server, downloader, "pause_task", "Pause task", "Pause one Gopeed task.", &nondestructive, &closedWorld, downloader.Pause) registerTaskAction(server, downloader, "pause_task", "Pause task", "Pause one Gopeed task.", &nondestructive, &closedWorld, downloader.Pause)
registerTaskAction(server, downloader, "continue_task", "Continue task", "Continue a paused or failed Gopeed task.", &nondestructive, &openWorld, downloader.Continue) registerTaskAction(server, downloader, "continue_task", "Continue task", "Continue a paused or failed Gopeed task.", &nondestructive, &openWorld, downloader.Continue)
+1
View File
@@ -64,6 +64,7 @@ func TestStreamableHTTPTools(t *testing.T) {
"continue_task", "continue_task",
"create_task", "create_task",
"delete_task", "delete_task",
"get_download_speed",
"get_task", "get_task",
"get_task_stats", "get_task_stats",
"get_task_status", "get_task_status",
+103
View File
@@ -0,0 +1,103 @@
package mcpserver
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestGetDownloadSpeedTool(t *testing.T) {
// Serve a test payload that takes a little bit of time or streams bytes
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "1048576")
w.WriteHeader(http.StatusOK)
chunk := make([]byte, 8192)
for i := 0; i < 128; i++ {
_, _ = w.Write(chunk)
time.Sleep(10 * time.Millisecond)
}
}))
defer testServer.Close()
downloader := download.NewDownloader(&download.DownloaderConfig{
Storage: download.NewMemStorage(),
StorageDir: t.TempDir(),
})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Close()
httpServer := httptest.NewServer(NewHandler(downloader))
defer httpServer.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "gopeed-test", Version: "1.0.0"}, nil)
session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, nil)
if err != nil {
t.Fatal(err)
}
defer session.Close()
// 1. Zero tasks test (returns 0 totalSpeed and 0 activeTaskCount)
result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "get_download_speed",
Arguments: map[string]any{},
})
if err != nil {
t.Fatal(err)
}
if result.IsError {
t.Fatalf("get_download_speed returned error: %+v", result.Content)
}
structured, ok := result.StructuredContent.(map[string]any)
if !ok {
t.Fatalf("structured content type = %T", result.StructuredContent)
}
if activeCount, ok := structured["activeTaskCount"].(float64); !ok || activeCount != 0 {
t.Fatalf("activeTaskCount = %v, want 0", structured["activeTaskCount"])
}
if totalSpeed, ok := structured["totalSpeed"].(float64); !ok || totalSpeed != 0 {
t.Fatalf("totalSpeed = %v, want 0", structured["totalSpeed"])
}
// 2. Start a real download task
taskID, err := downloader.CreateDirect(&base.Request{
URL: testServer.URL,
}, &base.Options{
Path: t.TempDir(),
})
if err != nil {
t.Fatal(err)
}
defer func() {
_ = downloader.Delete(&download.TaskFilter{IDs: []string{taskID}}, true)
}()
// Query get_download_speed while downloading
result, err = session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "get_download_speed",
Arguments: map[string]any{},
})
if err != nil {
t.Fatal(err)
}
if result.IsError {
t.Fatalf("get_download_speed returned error: %+v", result.Content)
}
structured, ok = result.StructuredContent.(map[string]any)
if !ok {
t.Fatalf("structured content type = %T", result.StructuredContent)
}
if _, ok := structured["activeTaskCount"].(float64); !ok {
t.Fatalf("activeTaskCount missing or wrong type: %v", structured["activeTaskCount"])
}
if _, ok := structured["totalSpeed"].(float64); !ok {
t.Fatalf("totalSpeed missing or wrong type: %v", structured["totalSpeed"])
}
}
+74
View File
@@ -1,11 +1,83 @@
package http 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"`
Body string `json:"body"` Body string `json:"body"`
} }
// ChecksumOption specifies an expected checksum to verify against
// the completed download. Only supported for HTTP downloads.
type ChecksumOption struct {
Algorithm string `json:"algorithm"` // "md5" | "sha1" | "sha256"
Expected string `json:"expected"` // hex-encoded expected hash
}
// IsEmpty returns true when chk is nil, or when both Algorithm and Expected
// are blank after trimming whitespace.
func (chk *ChecksumOption) IsEmpty() bool {
if chk == nil {
return true
}
return strings.TrimSpace(chk.Algorithm) == "" && strings.TrimSpace(chk.Expected) == ""
}
// NormalizedAlgorithm returns the canonical lower-cased algorithm ("md5", "sha1", "sha256")
// and true if recognized, or ("", false) if unsupported.
func (chk *ChecksumOption) NormalizedAlgorithm() (string, bool) {
if chk == nil {
return "", false
}
switch strings.ToLower(strings.TrimSpace(chk.Algorithm)) {
case "md5":
return "md5", true
case "sha1", "sha-1":
return "sha1", true
case "sha256", "sha-256":
return "sha256", true
default:
return "", false
}
}
// Validate checks whether the checksum option is valid.
// Empty options return nil (no verification).
func (chk *ChecksumOption) Validate() error {
if chk.IsEmpty() {
return nil
}
algo, ok := chk.NormalizedAlgorithm()
if !ok {
return fmt.Errorf("unsupported checksum algorithm: %s", chk.Algorithm)
}
expected := strings.TrimSpace(chk.Expected)
if expected == "" {
return fmt.Errorf("checksum expected hash is required when algorithm is set")
}
var expectedLen int
switch algo {
case "md5":
expectedLen = 32
case "sha1":
expectedLen = 40
case "sha256":
expectedLen = 64
}
if len(expected) != expectedLen {
return fmt.Errorf("invalid checksum hash length for %s: expected %d characters, got %d", algo, expectedLen, len(expected))
}
if _, err := hex.DecodeString(expected); err != nil {
return fmt.Errorf("invalid checksum hash for %s: expected valid hexadecimal string", algo)
}
return nil
}
type OptsExtra struct { 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
@@ -21,6 +93,8 @@ type OptsExtra struct {
ArchivePassword string `json:"archivePassword"` ArchivePassword string `json:"archivePassword"`
// DeleteAfterExtract when true, deletes the archive file after successful extraction // DeleteAfterExtract when true, deletes the archive file after successful extraction
DeleteAfterExtract bool `json:"deleteAfterExtract"` DeleteAfterExtract bool `json:"deleteAfterExtract"`
// Checksum verifies the completed file against an expected hash after download
Checksum *ChecksumOption `json:"checksum,omitempty"`
} }
// Stats for download // Stats for download
+162
View File
@@ -0,0 +1,162 @@
package http
import (
"strings"
"testing"
)
func TestOptsExtra_Checksum(t *testing.T) {
extra := &OptsExtra{
Checksum: &ChecksumOption{
Algorithm: "sha256",
Expected: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
}
if extra.Checksum.Algorithm != "sha256" || extra.Checksum.Expected != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
t.Fatalf("unexpected checksum: %+v", extra.Checksum)
}
}
func TestChecksumOption_Validate(t *testing.T) {
validMd5 := "0123456789abcdef0123456789abcdef"
validSha1 := "0123456789abcdef0123456789abcdef01234567"
validSha256 := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
tests := []struct {
name string
option *ChecksumOption
wantErr bool
errSubstr string
}{
{
name: "nil option",
option: nil,
wantErr: false,
},
{
name: "empty option (empty strings)",
option: &ChecksumOption{Algorithm: "", Expected: ""},
wantErr: false,
},
{
name: "empty option (whitespace strings)",
option: &ChecksumOption{Algorithm: " ", Expected: " "},
wantErr: false,
},
{
name: "valid md5 lowercase",
option: &ChecksumOption{Algorithm: "md5", Expected: validMd5},
wantErr: false,
},
{
name: "valid md5 uppercase",
option: &ChecksumOption{Algorithm: "MD5", Expected: strings.ToUpper(validMd5)},
wantErr: false,
},
{
name: "valid sha1 lowercase",
option: &ChecksumOption{Algorithm: "sha1", Expected: validSha1},
wantErr: false,
},
{
name: "valid sha-1 alias",
option: &ChecksumOption{Algorithm: "SHA-1", Expected: strings.ToUpper(validSha1)},
wantErr: false,
},
{
name: "valid sha256 lowercase",
option: &ChecksumOption{Algorithm: "sha256", Expected: validSha256},
wantErr: false,
},
{
name: "valid sha-256 alias",
option: &ChecksumOption{Algorithm: "sha-256", Expected: validSha256},
wantErr: false,
},
{
name: "valid sha256 with whitespace around hash and algorithm",
option: &ChecksumOption{Algorithm: " sha256 ", Expected: " " + validSha256 + " "},
wantErr: false,
},
{
name: "unknown algorithm",
option: &ChecksumOption{Algorithm: "crc32", Expected: "12345678"},
wantErr: true,
errSubstr: "unsupported checksum algorithm",
},
{
name: "algorithm set but hash blank",
option: &ChecksumOption{Algorithm: "sha256", Expected: " "},
wantErr: true,
errSubstr: "checksum expected hash is required",
},
{
name: "sha256 with abc (short length)",
option: &ChecksumOption{Algorithm: "sha256", Expected: "abc"},
wantErr: true,
errSubstr: "expected 64 characters, got 3",
},
{
name: "sha256 with 64 x g (non-hex)",
option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("g", 64)},
wantErr: true,
errSubstr: "expected valid hexadecimal string",
},
{
name: "sha256 with 63 x a (off-by-one short)",
option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("a", 63)},
wantErr: true,
errSubstr: "expected 64 characters, got 63",
},
{
name: "sha256 with 65 x a (off-by-one long)",
option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("a", 65)},
wantErr: true,
errSubstr: "expected 64 characters, got 65",
},
{
name: "md5 with 31 chars",
option: &ChecksumOption{Algorithm: "md5", Expected: strings.Repeat("a", 31)},
wantErr: true,
errSubstr: "expected 32 characters, got 31",
},
{
name: "md5 with 33 chars",
option: &ChecksumOption{Algorithm: "md5", Expected: strings.Repeat("a", 33)},
wantErr: true,
errSubstr: "expected 32 characters, got 33",
},
{
name: "md5 with sha256-length hash (64 chars)",
option: &ChecksumOption{Algorithm: "md5", Expected: validSha256},
wantErr: true,
errSubstr: "expected 32 characters, got 64",
},
{
name: "sha1 with 39 chars",
option: &ChecksumOption{Algorithm: "sha1", Expected: strings.Repeat("a", 39)},
wantErr: true,
errSubstr: "expected 40 characters, got 39",
},
{
name: "sha1 with 41 chars",
option: &ChecksumOption{Algorithm: "sha1", Expected: strings.Repeat("a", 41)},
wantErr: true,
errSubstr: "expected 40 characters, got 41",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.option.Validate()
if (err != nil) != tt.wantErr {
t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr && tt.errSubstr != "" {
if err == nil || !strings.Contains(err.Error(), tt.errSubstr) {
t.Fatalf("Validate() error = %v, want substring %q", err, tt.errSubstr)
}
}
})
}
}
+17
View File
@@ -17,6 +17,21 @@ class Options {
Map<String, dynamic> toJson() => _$OptionsToJson(this); Map<String, dynamic> toJson() => _$OptionsToJson(this);
} }
@JsonSerializable()
class ChecksumOption {
String algorithm;
String expected;
ChecksumOption({
this.algorithm = '',
this.expected = '',
});
factory ChecksumOption.fromJson(Map<String, dynamic> json) => _$ChecksumOptionFromJson(json);
Map<String, dynamic> toJson() => _$ChecksumOptionToJson(this);
}
@JsonSerializable() @JsonSerializable()
class OptsExtraHttp { class OptsExtraHttp {
int connections; int connections;
@@ -25,6 +40,7 @@ class OptsExtraHttp {
bool? autoExtract; bool? autoExtract;
String archivePassword; String archivePassword;
bool deleteAfterExtract; bool deleteAfterExtract;
ChecksumOption? checksum;
OptsExtraHttp({ OptsExtraHttp({
this.connections = 0, this.connections = 0,
@@ -33,6 +49,7 @@ class OptsExtraHttp {
this.autoExtract, this.autoExtract,
this.archivePassword = '', this.archivePassword = '',
this.deleteAfterExtract = false, this.deleteAfterExtract = false,
this.checksum,
}); });
factory OptsExtraHttp.fromJson(Map<String, dynamic> json) => _$OptsExtraHttpFromJson(json); factory OptsExtraHttp.fromJson(Map<String, dynamic> json) => _$OptsExtraHttpFromJson(json);
+14
View File
@@ -22,6 +22,16 @@ Map<String, dynamic> _$OptionsToJson(Options instance) => <String, dynamic>{
'extra': ?instance.extra, 'extra': ?instance.extra,
}; };
ChecksumOption _$ChecksumOptionFromJson(Map<String, dynamic> json) => ChecksumOption(
algorithm: json['algorithm'] as String? ?? '',
expected: json['expected'] as String? ?? '',
);
Map<String, dynamic> _$ChecksumOptionToJson(ChecksumOption instance) => <String, dynamic>{
'algorithm': instance.algorithm,
'expected': instance.expected,
};
OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHttp( OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHttp(
connections: (json['connections'] as num?)?.toInt() ?? 0, connections: (json['connections'] as num?)?.toInt() ?? 0,
autoTorrent: json['autoTorrent'] as bool?, autoTorrent: json['autoTorrent'] as bool?,
@@ -29,6 +39,9 @@ OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHtt
autoExtract: json['autoExtract'] as bool?, autoExtract: json['autoExtract'] as bool?,
archivePassword: json['archivePassword'] as String? ?? '', archivePassword: json['archivePassword'] as String? ?? '',
deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false, deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false,
checksum: json['checksum'] == null
? null
: ChecksumOption.fromJson(json['checksum'] as Map<String, dynamic>),
); );
Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, dynamic>{ Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, dynamic>{
@@ -38,4 +51,5 @@ Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, d
'autoExtract': ?instance.autoExtract, 'autoExtract': ?instance.autoExtract,
'archivePassword': instance.archivePassword, 'archivePassword': instance.archivePassword,
'deleteAfterExtract': instance.deleteAfterExtract, 'deleteAfterExtract': instance.deleteAfterExtract,
'checksum': ?instance.checksum?.toJson(),
}; };
@@ -185,12 +185,11 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
} }
bool canUpdateItem(ExtensionListItem item) { bool canUpdateItem(ExtensionListItem item) {
final current = _current; final installed = item.installed;
if (item.installed == null) return false; if (installed == null) return false;
if (current.listFilter == ExtensionListFilter.market && item.store != null) { // The backend checks GitHub live for every installed extension; the store
return _compareVersion(item.store!.version, item.installed!.version) > 0; // index may lag behind and must not decide update visibility.
} return _current.updateFlags.containsKey(installed.identity);
return current.updateFlags.containsKey(item.installed!.identity);
} }
Future<void> installFromStore(StoreExtension extension) async { Future<void> installFromStore(StoreExtension extension) async {
@@ -240,6 +239,9 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
await _runBusy(extension.identity, () async { await _runBusy(extension.identity, () async {
await ref.read(gopeedServiceProvider).updateExtension(extension.identity); await ref.read(gopeedServiceProvider).updateExtension(extension.identity);
await loadInstalled(refreshUpdates: false); await loadInstalled(refreshUpdates: false);
// Drop the stale flag immediately; the background re-check confirms it.
final flags = Map<String, String>.of(_current.updateFlags)..remove(extension.identity);
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
unawaited(checkUpdate()); unawaited(checkUpdate());
_bumpStoreInstallCount(extension.identity); _bumpStoreInstallCount(extension.identity);
_reportInstallSafe(extension.identity); _reportInstallSafe(extension.identity);
@@ -354,24 +356,4 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
}()); }());
} }
static int _compareVersion(String a, String b) {
final aNums = _toVersionNumbers(a);
final bNums = _toVersionNumbers(b);
final maxLen = aNums.length > bNums.length ? aNums.length : bNums.length;
for (var i = 0; i < maxLen; i++) {
final left = i < aNums.length ? aNums[i] : 0;
final right = i < bNums.length ? bNums[i] : 0;
if (left > right) return 1;
if (left < right) return -1;
}
return 0;
}
static List<int> _toVersionNumbers(String version) {
return version
.split(RegExp(r'[^0-9]+'))
.where((part) => part.isNotEmpty)
.map((part) => int.tryParse(part) ?? 0)
.toList();
}
} }
@@ -8,6 +8,8 @@ import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/detail/app_detail_surface.dart'; import '../../../../shared/widgets/detail/app_detail_surface.dart';
import '../../application/extensions_controller.dart'; import '../../application/extensions_controller.dart';
import '../widgets/extension_detail_view.dart'; import '../widgets/extension_detail_view.dart';
import '../widgets/extension_update_dialog.dart';
import '../widgets/extension_update_status.dart';
class ExtensionDetailsPage extends ConsumerWidget { class ExtensionDetailsPage extends ConsumerWidget {
const ExtensionDetailsPage({super.key, required this.extensionId, this.initialItem}); const ExtensionDetailsPage({super.key, required this.extensionId, this.initialItem});
@@ -24,6 +26,15 @@ class ExtensionDetailsPage extends ConsumerWidget {
return AppDetailPage( return AppDetailPage(
title: item?.title ?? context.l10n.extensions, title: item?.title ?? context.l10n.extensions,
onBack: () => context.canPop() ? context.pop() : context.go('/extensions'), onBack: () => context.canPop() ? context.pop() : context.go('/extensions'),
titleTrailing: _canUpdate(stateAsync.value, item)
? ExtensionUpdateStatus(
key: const ValueKey('extension-details-update-mobile'),
label: context.l10n.extensionCanUpdate,
onTap: item?.installed == null
? null
: () => showExtensionUpdateDialog(context, item!.installed!),
)
: null,
child: item == null child: item == null
? Center( ? Center(
child: stateAsync.isLoading child: stateAsync.isLoading
@@ -33,4 +44,9 @@ class ExtensionDetailsPage extends ConsumerWidget {
: ExtensionDetailView(key: ValueKey(item.id), item: item, mobile: true), : ExtensionDetailView(key: ValueKey(item.id), item: item, mobile: true),
); );
} }
bool _canUpdate(ExtensionsState? state, ExtensionListItem? item) {
final installed = item?.installed;
return item != null && installed != null && (state?.updateFlags.containsKey(installed.identity) ?? false);
}
} }
@@ -28,6 +28,7 @@ import '../widgets/extension_detail_view.dart';
import '../widgets/extension_icon.dart'; import '../widgets/extension_icon.dart';
import '../widgets/extension_setting_field.dart'; import '../widgets/extension_setting_field.dart';
import '../widgets/extension_update_dialog.dart'; import '../widgets/extension_update_dialog.dart';
import '../widgets/extension_update_status.dart';
const _extensionCardMinWidth = 290.0; const _extensionCardMinWidth = 290.0;
const _extensionGridSpacing = 10.0; const _extensionGridSpacing = 10.0;
@@ -485,25 +486,7 @@ class _Content extends StatelessWidget {
} }
bool _canUpdate(ExtensionsState state, ExtensionListItem item) { bool _canUpdate(ExtensionsState state, ExtensionListItem item) {
if (item.installed == null) return false; return item.installed != null && state.updateFlags.containsKey(item.installed!.identity);
if (state.listFilter == ExtensionListFilter.market && item.store != null) {
return _compareVersion(item.store!.version, item.installed!.version) > 0;
}
return state.updateFlags.containsKey(item.installed!.identity);
}
int _compareVersion(String a, String b) {
final aNums = a.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0);
final bNums = b.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0);
final left = aNums.toList();
final right = bNums.toList();
final maxLen = left.length > right.length ? left.length : right.length;
for (var i = 0; i < maxLen; i++) {
final l = i < left.length ? left[i] : 0;
final r = i < right.length ? right[i] : 0;
if (l != r) return l.compareTo(r);
}
return 0;
} }
} }
@@ -994,12 +977,29 @@ class _ExtensionCard extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
children: [
Flexible(
child: Text(
item.title, item.title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700), style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700),
), ),
),
if (canUpdate && installed != null) ...[
const SizedBox(width: 4),
Flexible(
child: ExtensionUpdateStatus(
key: ValueKey('extension-card-update-status-${item.id}'),
label: context.l10n.extensionCanUpdate,
onTap: busy ? null : () => showExtensionUpdateDialog(context, installed),
compact: true,
),
),
],
],
),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'${item.author} · v${item.version}', '${item.author} · v${item.version}',
@@ -1067,13 +1067,6 @@ class _ExtensionCard extends ConsumerWidget {
key: ValueKey('extension-card-actions-${item.id}'), key: ValueKey('extension-card-actions-${item.id}'),
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (canUpdate && installed != null)
shad.GhostButton(
density: shad.ButtonDensity.icon,
key: ValueKey('update-extension-${installed.identity}'),
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
child: const Icon(Icons.refresh),
),
if (installed == null && item.store != null) if (installed == null && item.store != null)
shad.GhostButton( shad.GhostButton(
key: ValueKey('install-store-extension-${item.store!.id}'), key: ValueKey('install-store-extension-${item.store!.id}'),
@@ -1127,10 +1120,7 @@ class _ExtensionCard extends ConsumerWidget {
], ],
), ),
); );
if (item.store == null) return card; return GestureDetector(
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
key: ValueKey('open-extension-details-${item.id}'), key: ValueKey('open-extension-details-${item.id}'),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () { onTap: () {
@@ -1141,7 +1131,6 @@ class _ExtensionCard extends ConsumerWidget {
onOpenDetails(item); onOpenDetails(item);
}, },
child: card, child: card,
),
); );
} }
} }
@@ -21,20 +21,33 @@ import '../../../../util/util.dart';
import '../../application/extensions_controller.dart'; import '../../application/extensions_controller.dart';
import 'extension_icon.dart'; import 'extension_icon.dart';
import 'extension_update_dialog.dart'; import 'extension_update_dialog.dart';
import 'extension_update_status.dart';
class ExtensionDetailDrawer extends StatelessWidget { class ExtensionDetailDrawer extends ConsumerWidget {
const ExtensionDetailDrawer({super.key, required this.item, required this.onClose}); const ExtensionDetailDrawer({super.key, required this.item, required this.onClose});
final ExtensionListItem? item; final ExtensionListItem? item;
final VoidCallback onClose; final VoidCallback onClose;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(extensionsControllerProvider).value;
final installed = item?.installed;
final canUpdate =
item != null && installed != null && (state?.updateFlags.containsKey(installed.identity) ?? false);
final updateChip = canUpdate
? ExtensionUpdateStatus(
key: const ValueKey('extension-details-update'),
label: context.l10n.extensionCanUpdate,
onTap: () => showExtensionUpdateDialog(context, installed),
)
: null;
return AppDetailDrawer( return AppDetailDrawer(
open: item != null, open: item != null,
title: item?.title ?? '', title: item?.title ?? '',
onClose: onClose, onClose: onClose,
drawerKey: const ValueKey('extension-details-drawer'), drawerKey: const ValueKey('extension-details-drawer'),
titleTrailing: updateChip,
child: item == null ? const SizedBox.shrink() : ExtensionDetailView(item: item!), child: item == null ? const SizedBox.shrink() : ExtensionDetailView(item: item!),
); );
} }
@@ -104,8 +117,6 @@ class ExtensionDetailView extends ConsumerWidget {
style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600), style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600),
), ),
if (store != null) _ExtensionStats(store: store), if (store != null) _ExtensionStats(store: store),
if (installed != null && canUpdate)
_ExtensionStatus(label: context.l10n.extensionCanUpdate, emphasized: true),
], ],
), ),
], ],
@@ -130,15 +141,6 @@ class ExtensionDetailView extends ConsumerWidget {
_runAction(context, () => ref.read(extensionsControllerProvider.notifier).installFromStore(store)), _runAction(context, () => ref.read(extensionsControllerProvider.notifier).installFromStore(store)),
child: Text(context.l10n.extensionInstall), child: Text(context.l10n.extensionInstall),
), ),
if (installed != null && canUpdate)
AppLoadingButton(
key: const ValueKey('extension-details-update'),
loading: false,
variant: AppLoadingButtonVariant.primary,
icon: const Icon(Icons.refresh, size: 16),
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
child: Text(context.l10n.newVersionUpdate),
),
if (installed != null && !canUpdate) if (installed != null && !canUpdate)
AppLoadingButton( AppLoadingButton(
key: const ValueKey('extension-details-installed'), key: const ValueKey('extension-details-installed'),
@@ -162,7 +164,13 @@ class ExtensionDetailView extends ConsumerWidget {
), ),
], ],
), ),
if (store != null) ...[SizedBox(height: mobile ? 24 : 28), _ExtensionReadme(item: current, mobile: mobile)], SizedBox(height: mobile ? 24 : 28),
_ExtensionReadme(
// Snapshot the version so upgrades reload the installed README.
key: ValueKey((current.id, installed?.version, installed?.devMode, installed?.devPath, store?.readme)),
item: current,
mobile: mobile,
),
], ],
); );
} }
@@ -180,7 +188,7 @@ class ExtensionDetailView extends ConsumerWidget {
} }
class _ExtensionReadme extends StatefulWidget { class _ExtensionReadme extends StatefulWidget {
const _ExtensionReadme({required this.item, required this.mobile}); const _ExtensionReadme({super.key, required this.item, required this.mobile});
final ExtensionListItem item; final ExtensionListItem item;
final bool mobile; final bool mobile;
@@ -190,7 +198,7 @@ class _ExtensionReadme extends StatefulWidget {
} }
class _ExtensionReadmeState extends State<_ExtensionReadme> { class _ExtensionReadmeState extends State<_ExtensionReadme> {
late Future<_ReadmeInfo> _readme; late final _ReadmeInfo _readme;
@override @override
void initState() { void initState() {
@@ -198,32 +206,11 @@ 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 ||
previous?.devMode != current?.devMode ||
previous?.devPath != current?.devPath ||
oldWidget.item.store?.readme != widget.item.store?.readme) {
_readme = _loadReadme(widget.item);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final palette = AppPalette.of(context); final palette = AppPalette.of(context);
return FutureBuilder<_ReadmeInfo>( final info = _readme;
future: _readme, if (info.content.trim().isEmpty) return const SizedBox.shrink();
builder: (context, snapshot) {
final info = snapshot.data;
if (info == null) {
return const SizedBox.shrink();
}
if (info.content.trim().isEmpty) {
return const SizedBox.shrink();
}
return Padding( return Padding(
key: const ValueKey('extension-details-readme'), key: const ValueKey('extension-details-readme'),
padding: EdgeInsets.only(top: widget.mobile ? 32 : 36), padding: EdgeInsets.only(top: widget.mobile ? 32 : 36),
@@ -232,12 +219,7 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> {
children: [ children: [
Text( Text(
'README', 'README',
style: TextStyle( style: TextStyle(color: palette.textMuted, fontSize: 10.5, fontWeight: FontWeight.w800, letterSpacing: 1.2),
color: palette.textMuted,
fontSize: 10.5,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
MarkdownBody( MarkdownBody(
@@ -257,11 +239,7 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> {
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (_, _, _) => const SizedBox.shrink(), errorBuilder: (_, _, _) => const SizedBox.shrink(),
) )
: Image.network( : Image.network(resolved, fit: BoxFit.contain, errorBuilder: (_, _, _) => const SizedBox.shrink());
resolved,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 10), padding: const EdgeInsets.symmetric(vertical: 10),
child: ClipRRect(borderRadius: BorderRadius.circular(6), child: image), child: ClipRRect(borderRadius: BorderRadius.circular(6), child: image),
@@ -271,37 +249,6 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> {
], ],
), ),
); );
},
);
}
}
class _ExtensionStatus extends StatelessWidget {
const _ExtensionStatus({required this.label, required this.emphasized});
final String label;
final bool emphasized;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final dotColor = emphasized ? palette.brand : palette.success;
final textColor = emphasized ? palette.textPrimary : palette.success;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 5,
height: 5,
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(color: textColor, fontSize: 11.5, fontWeight: FontWeight.w600),
),
],
);
} }
} }
@@ -407,7 +354,7 @@ MarkdownStyleSheet _markdownStyle(AppPalette palette, {required bool mobile}) {
); );
} }
Future<_ReadmeInfo> _loadReadme(ExtensionListItem item) async { _ReadmeInfo _loadReadme(ExtensionListItem item) {
final installed = item.installed; final 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);
@@ -58,7 +58,7 @@ class _ExtensionUpdateDialogState extends ConsumerState<_ExtensionUpdateDialog>
canPop: !_updating, canPop: !_updating,
child: AlertDialog( child: AlertDialog(
key: const ValueKey('extension-update-dialog'), key: const ValueKey('extension-update-dialog'),
title: Text(context.l10n.extensionCanUpdate), title: Text(context.l10n.newVersionAvailable),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -0,0 +1,59 @@
import 'package:flutter/widgets.dart';
import '../../../../shared/theme/app_palette.dart';
/// Green status dot with an optional label, marking an available update.
/// Pass [onTap] to make it open the update prompt.
class ExtensionUpdateStatus extends StatelessWidget {
const ExtensionUpdateStatus({super.key, this.label, this.onTap, this.compact = false});
final String? label;
final VoidCallback? onTap;
final bool compact;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final content = Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: compact ? 2 : 3),
decoration: BoxDecoration(
color: palette.brandSoft,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: palette.brand.withValues(alpha: 0.35)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: palette.brand, shape: BoxShape.circle),
),
if (label != null) ...[
const SizedBox(width: 5),
Flexible(
child: Text(
label!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 11.5, fontWeight: FontWeight.w600),
),
),
],
],
),
);
if (onTap == null) return content;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 2, vertical: compact ? 2 : 4),
child: content,
),
),
);
}
}
@@ -66,6 +66,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
final _httpHeadersController = AppHttpHeadersController(defaultNames: const ['User-Agent', 'Cookie', 'Referer']); final _httpHeadersController = AppHttpHeadersController(defaultNames: const ['User-Agent', 'Cookie', 'Referer']);
final _trackersController = TextEditingController(); final _trackersController = TextEditingController();
final _archivePasswordController = TextEditingController(); final _archivePasswordController = TextEditingController();
final _checksumHashController = TextEditingController();
final _formScrollController = ScrollController(); final _formScrollController = ScrollController();
String _httpMethod = 'GET'; String _httpMethod = 'GET';
@@ -74,6 +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';
bool _skipVerifyCert = false; bool _skipVerifyCert = false;
bool? _autoTorrent; bool? _autoTorrent;
bool? _deleteTorrentAfterDownload; bool? _deleteTorrentAfterDownload;
@@ -134,6 +136,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_httpHeadersController.dispose(); _httpHeadersController.dispose();
_trackersController.dispose(); _trackersController.dispose();
_archivePasswordController.dispose(); _archivePasswordController.dispose();
_checksumHashController.dispose();
_formScrollController.dispose(); _formScrollController.dispose();
super.dispose(); super.dispose();
} }
@@ -184,6 +187,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) { if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) {
_fileDataUri = ''; _fileDataUri = '';
} }
if (_inputUrls().length > 1) {
_checksumHashController.clear();
_checksumAlgorithm = 'md5';
}
} }
_recognizeMagnetUri(urlText.trim()); _recognizeMagnetUri(urlText.trim());
} }
@@ -193,6 +200,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
final req = task.req; final req = task.req;
final opts = task.opts; final opts = task.opts;
setState(() { setState(() {
_checksumAlgorithm = 'md5';
_checksumHashController.clear();
if (req?.url.isNotEmpty == true) { if (req?.url.isNotEmpty == true) {
_fileDataUri = ''; _fileDataUri = '';
_renameController.clear(); _renameController.clear();
@@ -243,6 +252,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_autoExtract = extra.autoExtract; _autoExtract = extra.autoExtract;
_archivePasswordController.text = extra.archivePassword; _archivePasswordController.text = extra.archivePassword;
_deleteAfterExtract = extra.deleteAfterExtract; _deleteAfterExtract = extra.deleteAfterExtract;
if (extra.checksum != null) {
if (extra.checksum!.algorithm.isNotEmpty) {
_checksumAlgorithm = extra.checksum!.algorithm;
}
_checksumHashController.text = extra.checksum!.expected;
}
} }
} }
}); });
@@ -668,6 +683,34 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
), ),
), ),
], ],
if (_inputUrls().length <= 1) ...[
const SizedBox(height: 14),
AppFormPair(
direction: stacked ? Axis.vertical : Axis.horizontal,
firstLabel: context.l10n.checksumAlgorithm,
secondLabel: context.l10n.checksumHash,
firstFlex: 382,
secondFlex: 618,
first: AppChoiceSegmentedControl<String>(
key: const ValueKey('create-task-checksum-algorithm'),
showIcons: false,
value: _checksumAlgorithm,
buttonKeyPrefix: 'create-task-checksum-algorithm',
alignment: WrapAlignment.start,
options: const [
AppChoiceOption(value: 'md5', label: 'MD5', icon: Icons.tag),
AppChoiceOption(value: 'sha1', label: 'SHA-1', icon: Icons.tag),
AppChoiceOption(value: 'sha256', label: 'SHA-256', icon: Icons.tag),
],
onChanged: (value) => setState(() => _checksumAlgorithm = value),
),
second: _WindowTextField(
key: const ValueKey('create-task-checksum-expected'),
controller: _checksumHashController,
hintText: context.l10n.checksumExpectedHint,
),
),
],
] else ...[ ] else ...[
AppFormRow( AppFormRow(
direction: stacked ? Axis.vertical : Axis.horizontal, direction: stacked ? Axis.vertical : Axis.horizontal,
@@ -950,6 +993,25 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
} }
bool _validateAdvancedOptions() { bool _validateAdvancedOptions() {
if (_protocolTab == 0 && _inputUrls().length <= 1) {
final checksumExpected = _checksumHashController.text.trim();
if (checksumExpected.isNotEmpty) {
final expectedLen = switch (_checksumAlgorithm.toLowerCase()) {
'md5' => 32,
'sha1' || 'sha-1' => 40,
'sha256' || 'sha-256' => 64,
_ => 0,
};
if (expectedLen > 0 && checksumExpected.length != expectedLen) {
_showToast(context.l10n.checksumInvalidLength(_checksumAlgorithm.toUpperCase(), expectedLen.toString()));
return false;
}
if (!RegExp(r'^[0-9a-fA-F]+$').hasMatch(checksumExpected)) {
_showToast(context.l10n.checksumInvalidHex);
return false;
}
}
}
if (_proxyMode != RequestProxyMode.custom) { if (_proxyMode != RequestProxyMode.custom) {
return true; return true;
} }
@@ -1022,8 +1084,15 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
return text.length == 40 && RegExp(r'^[0-9a-fA-F]+$').hasMatch(text); return text.length == 40 && RegExp(r'^[0-9a-fA-F]+$').hasMatch(text);
} }
api_options.Options _buildOptions({String? name, List<int> selectFiles = const []}) { api_options.Options _buildOptions({String? name, List<int> selectFiles = const [], bool omitChecksum = false}) {
final connections = int.tryParse(_connectionsController.text.trim()) ?? 0; final connections = int.tryParse(_connectionsController.text.trim()) ?? 0;
final checksumExpected = _checksumHashController.text.trim();
final checksum = (!omitChecksum && _inputUrls().length <= 1 && checksumExpected.isNotEmpty)
? api_options.ChecksumOption(
algorithm: _checksumAlgorithm,
expected: checksumExpected,
)
: null;
return api_options.Options( return api_options.Options(
name: name ?? _renameController.text.trim(), name: name ?? _renameController.text.trim(),
path: _directoryController.text.trim(), path: _directoryController.text.trim(),
@@ -1036,6 +1105,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
autoExtract: _autoExtract, autoExtract: _autoExtract,
archivePassword: _archivePasswordController.text, archivePassword: _archivePasswordController.text,
deleteAfterExtract: _deleteAfterExtract, deleteAfterExtract: _deleteAfterExtract,
checksum: checksum,
).toJson(), ).toJson(),
); );
} }
@@ -1050,13 +1120,14 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
Future<void> _submitResolved(Request request, ResolveResult result, List<int> selectedIndexes) async { Future<void> _submitResolved(Request request, ResolveResult result, List<int> selectedIndexes) async {
final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList()) final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList())
..sort(); ..sort();
final isMultiFile = selected.length > 1;
if (result.id.isNotEmpty) { if (result.id.isNotEmpty) {
await ref await ref
.read(gopeedServiceProvider) .read(gopeedServiceProvider)
.createTask( .createTask(
CreateTask( CreateTask(
rid: result.id, rid: result.id,
opts: _buildOptions(selectFiles: selected), opts: _buildOptions(selectFiles: selected, omitChecksum: isMultiFile),
), ),
); );
return; return;
@@ -1070,11 +1141,11 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
opts: api_options.Options( opts: api_options.Options(
name: file.name, name: file.name,
path: path.join(_directoryController.text.trim(), result.res.name, file.path), path: path.join(_directoryController.text.trim(), result.res.name, file.path),
extra: _buildOptions().extra, extra: _buildOptions(omitChecksum: isMultiFile).extra,
), ),
); );
}).toList(); }).toList();
await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions())); await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions(omitChecksum: isMultiFile)));
return; return;
} }
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "إضافة", "add": "إضافة",
"edit": "تعديل", "edit": "تعديل",
"newVersionTitle": "يتوفر إصدار جديد @version", "newVersionTitle": "يتوفر إصدار جديد @version",
"newVersionUpdate": "تحديث الآن", "newVersionUpdate": "تحديث",
"newVersionLater": "لاحقاً", "newVersionLater": "لاحقاً",
"extensions": "الإضافات", "extensions": "الإضافات",
"extensionInstallUrl": "عنوان التثبيت", "extensionInstallUrl": "عنوان التثبيت",
@@ -164,6 +164,11 @@
"autoExtract": "فك الضغط تلقائياً", "autoExtract": "فك الضغط تلقائياً",
"archivePassword": "كلمة مرور الملف المضغوط", "archivePassword": "كلمة مرور الملف المضغوط",
"archivePasswordHint": "اتركه فارغاً إذا لم يكن محمياً بكلمة مرور", "archivePasswordHint": "اتركه فارغاً إذا لم يكن محمياً بكلمة مرور",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "حذف الملف المضغوط بعد فك الضغط", "deleteAfterExtract": "حذف الملف المضغوط بعد فك الضغط",
"extracting": "جاري فك الضغط", "extracting": "جاري فك الضغط",
"extractDone": "تم فك الضغط بنجاح", "extractDone": "تم فك الضغط بنجاح",
+6 -1
View File
@@ -106,7 +106,7 @@
"add": "Əlavə et", "add": "Əlavə et",
"edit": "Redaktə et", "edit": "Redaktə et",
"newVersionTitle": "Yeni versiya @version tapıldı", "newVersionTitle": "Yeni versiya @version tapıldı",
"newVersionUpdate": "İndi Yenilə", "newVersionUpdate": "Yenilə",
"newVersionLater": "Sonra", "newVersionLater": "Sonra",
"extensions": "Uzantılar", "extensions": "Uzantılar",
"extensionInstallUrl": "Quraşdırma URL-i", "extensionInstallUrl": "Quraşdırma URL-i",
@@ -171,6 +171,11 @@
"autoExtract": "Arxivləri Avtomatik Çıxar", "autoExtract": "Arxivləri Avtomatik Çıxar",
"archivePassword": "Arxiv Parolu", "archivePassword": "Arxiv Parolu",
"archivePasswordHint": "Parolla qorunmayıbsa boş buraxın", "archivePasswordHint": "Parolla qorunmayıbsa boş buraxın",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Çıxarıldıqdan sonra arxivi sil", "deleteAfterExtract": "Çıxarıldıqdan sonra arxivi sil",
"extracting": "Çıxarılır", "extracting": "Çıxarılır",
"extractDone": "Çıxarma tamamlandı", "extractDone": "Çıxarma tamamlandı",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Afegeix", "add": "Afegeix",
"edit": "Edita", "edit": "Edita",
"newVersionTitle": "Descobriu la nova versió @version", "newVersionTitle": "Descobriu la nova versió @version",
"newVersionUpdate": "Actualitza ara", "newVersionUpdate": "Actualitza",
"newVersionLater": "Més tard", "newVersionLater": "Més tard",
"extensions": "Extensions", "extensions": "Extensions",
"extensionInstallUrl": "URL d'instal·lació", "extensionInstallUrl": "URL d'instal·lació",
@@ -164,6 +164,11 @@
"autoExtract": "Extreu els comprimits automàticament", "autoExtract": "Extreu els comprimits automàticament",
"archivePassword": "Contrasenya del comprimit", "archivePassword": "Contrasenya del comprimit",
"archivePasswordHint": "Deixeu-lo en blanc si no està protegit amb contrasenya", "archivePasswordHint": "Deixeu-lo en blanc si no està protegit amb contrasenya",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Suprimeix el comprimit després de l'extracció", "deleteAfterExtract": "Suprimeix el comprimit després de l'extracció",
"extracting": "S'està extraient", "extracting": "S'està extraient",
"extractDone": "Extracció completada", "extractDone": "Extracció completada",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Hinzugefügt", "add": "Hinzugefügt",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"newVersionTitle": "Entdecken Sie die neue Version @version", "newVersionTitle": "Entdecken Sie die neue Version @version",
"newVersionUpdate": "Jetzt aktualisieren", "newVersionUpdate": "Aktualisieren",
"newVersionLater": "Später", "newVersionLater": "Später",
"extensions": "Erweiterungen", "extensions": "Erweiterungen",
"extensionInstallUrl": "Installations-URL", "extensionInstallUrl": "Installations-URL",
@@ -164,6 +164,11 @@
"autoExtract": "Archive automatisch entpacken", "autoExtract": "Archive automatisch entpacken",
"archivePassword": "Archiv-Passwort", "archivePassword": "Archiv-Passwort",
"archivePasswordHint": "Leer lassen, wenn nicht passwortgeschützt", "archivePasswordHint": "Leer lassen, wenn nicht passwortgeschützt",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Archiv nach Entpacken löschen", "deleteAfterExtract": "Archiv nach Entpacken löschen",
"extracting": "Wird entpackt", "extracting": "Wird entpackt",
"extractDone": "Entpacken abgeschlossen", "extractDone": "Entpacken abgeschlossen",
+7 -2
View File
@@ -106,7 +106,7 @@
"add": "Add", "add": "Add",
"edit": "Edit", "edit": "Edit",
"newVersionTitle": "Discover new version @version", "newVersionTitle": "Discover new version @version",
"newVersionUpdate": "Update Now", "newVersionUpdate": "Update",
"newVersionLater": "Later", "newVersionLater": "Later",
"extensions": "Extensions", "extensions": "Extensions",
"extensionInstallUrl": "Install URL", "extensionInstallUrl": "Install URL",
@@ -137,7 +137,7 @@
"extensionLoadMore": "Load More", "extensionLoadMore": "Load More",
"extensionNoMore": "No more extensions", "extensionNoMore": "No more extensions",
"extensionInstalled": "Installed", "extensionInstalled": "Installed",
"extensionCanUpdate": "Update Available", "extensionCanUpdate": "Update",
"extensionInstall": "Install", "extensionInstall": "Install",
"extensionLoadLocal": "Install Local Folder", "extensionLoadLocal": "Install Local Folder",
"extensionFilterAll": "All", "extensionFilterAll": "All",
@@ -178,6 +178,11 @@
"autoExtract": "Extract Archives Automatically", "autoExtract": "Extract Archives Automatically",
"archivePassword": "Archive Password", "archivePassword": "Archive Password",
"archivePasswordHint": "Leave empty if not password protected", "archivePasswordHint": "Leave empty if not password protected",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Delete Archives After Extraction", "deleteAfterExtract": "Delete Archives After Extraction",
"extracting": "Extracting", "extracting": "Extracting",
"extractDone": "Extraction completed", "extractDone": "Extraction completed",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Añadir", "add": "Añadir",
"edit": "Editar", "edit": "Editar",
"newVersionTitle": "Nueva versión @version disponible", "newVersionTitle": "Nueva versión @version disponible",
"newVersionUpdate": "Actualizar Ahora", "newVersionUpdate": "Actualizar",
"newVersionLater": "Más tarde", "newVersionLater": "Más tarde",
"extensions": "Extensiones", "extensions": "Extensiones",
"extensionInstallUrl": "URL de instalación", "extensionInstallUrl": "URL de instalación",
@@ -164,6 +164,11 @@
"autoExtract": "Extraer archivos automáticamente", "autoExtract": "Extraer archivos automáticamente",
"archivePassword": "Contraseña del archivo", "archivePassword": "Contraseña del archivo",
"archivePasswordHint": "Dejar vacío si no tiene contraseña", "archivePasswordHint": "Dejar vacío si no tiene contraseña",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Eliminar archivo después de extraer", "deleteAfterExtract": "Eliminar archivo después de extraer",
"extracting": "Extrayendo", "extracting": "Extrayendo",
"extractDone": "Extracción completada", "extractDone": "Extracción completada",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "افزودن", "add": "افزودن",
"edit": "ویرایش", "edit": "ویرایش",
"newVersionTitle": "@version عنوان: کشف نسخه جدید", "newVersionTitle": "@version عنوان: کشف نسخه جدید",
"newVersionUpdate": "بروزرسانی", "newVersionUpdate": "به‌روزرسانی",
"newVersionLater": "بعداً", "newVersionLater": "بعداً",
"extensions": "افزونه‌ها", "extensions": "افزونه‌ها",
"extensionInstallUrl": "نشانی نصب", "extensionInstallUrl": "نشانی نصب",
@@ -164,6 +164,11 @@
"autoExtract": "استخراج خودکار بایگانی‌ها", "autoExtract": "استخراج خودکار بایگانی‌ها",
"archivePassword": "رمز عبور بایگانی", "archivePassword": "رمز عبور بایگانی",
"archivePasswordHint": "اگر رمز ندارد خالی بگذارید", "archivePasswordHint": "اگر رمز ندارد خالی بگذارید",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "حذف آرشیو پس از استخراج", "deleteAfterExtract": "حذف آرشیو پس از استخراج",
"extracting": "در حال استخراج", "extracting": "در حال استخراج",
"extractDone": "استخراج کامل شد", "extractDone": "استخراج کامل شد",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Ajouter", "add": "Ajouter",
"edit": "Modifier", "edit": "Modifier",
"newVersionTitle": "Nouvelle version @version disponible", "newVersionTitle": "Nouvelle version @version disponible",
"newVersionUpdate": "Mettre à jour maintenant", "newVersionUpdate": "Mettre à jour",
"newVersionLater": "Plus tard", "newVersionLater": "Plus tard",
"extensions": "Extensions", "extensions": "Extensions",
"extensionInstallUrl": "URL d'installation", "extensionInstallUrl": "URL d'installation",
@@ -164,6 +164,11 @@
"autoExtract": "Extraire automatiquement les archives", "autoExtract": "Extraire automatiquement les archives",
"archivePassword": "Mot de passe de l'archive", "archivePassword": "Mot de passe de l'archive",
"archivePasswordHint": "Laisser vide si non protégé par mot de passe", "archivePasswordHint": "Laisser vide si non protégé par mot de passe",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Supprimer l'archive après extraction", "deleteAfterExtract": "Supprimer l'archive après extraction",
"extracting": "Extraction en cours", "extracting": "Extraction en cours",
"extractDone": "Extraction terminée", "extractDone": "Extraction terminée",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Hozzáad", "add": "Hozzáad",
"edit": "Szerk", "edit": "Szerk",
"newVersionTitle": "Új verzió felfedezése @version", "newVersionTitle": "Új verzió felfedezése @version",
"newVersionUpdate": "Frissítés most", "newVersionUpdate": "Frissítés",
"newVersionLater": "Később", "newVersionLater": "Később",
"extensions": "Bővítmények", "extensions": "Bővítmények",
"extensionInstallUrl": "Telepítési URL", "extensionInstallUrl": "Telepítési URL",
@@ -164,6 +164,11 @@
"autoExtract": "Archívumok automatikus kicsomagolása", "autoExtract": "Archívumok automatikus kicsomagolása",
"archivePassword": "Archívum jelszó", "archivePassword": "Archívum jelszó",
"archivePasswordHint": "Hagyja üresen, ha nincs jelszóval védve", "archivePasswordHint": "Hagyja üresen, ha nincs jelszóval védve",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Archívum törlése kicsomagolás után", "deleteAfterExtract": "Archívum törlése kicsomagolás után",
"extracting": "Kicsomagolás", "extracting": "Kicsomagolás",
"extractDone": "Kicsomagolás befejezve", "extractDone": "Kicsomagolás befejezve",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Tambah", "add": "Tambah",
"edit": "Sunting", "edit": "Sunting",
"newVersionTitle": "Temukan versi batu @version", "newVersionTitle": "Temukan versi batu @version",
"newVersionUpdate": "Perbarui Sekarang", "newVersionUpdate": "Perbarui",
"newVersionLater": "Nanti", "newVersionLater": "Nanti",
"extensions": "Ekstensi", "extensions": "Ekstensi",
"extensionInstallUrl": "URL Instal", "extensionInstallUrl": "URL Instal",
@@ -164,6 +164,11 @@
"autoExtract": "Ekstrak Arsip Otomatis", "autoExtract": "Ekstrak Arsip Otomatis",
"archivePassword": "Kata Sandi Arsip", "archivePassword": "Kata Sandi Arsip",
"archivePasswordHint": "Kosongkan jika tidak dilindungi kata sandi", "archivePasswordHint": "Kosongkan jika tidak dilindungi kata sandi",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Hapus arsip setelah ekstraksi", "deleteAfterExtract": "Hapus arsip setelah ekstraksi",
"extracting": "Mengekstrak", "extracting": "Mengekstrak",
"extractDone": "Ekstraksi selesai", "extractDone": "Ekstraksi selesai",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Aggiungi", "add": "Aggiungi",
"edit": "Modifica", "edit": "Modifica",
"newVersionTitle": "Scopri la nuova versione @version", "newVersionTitle": "Scopri la nuova versione @version",
"newVersionUpdate": "Aggiorna ora", "newVersionUpdate": "Aggiorna",
"newVersionLater": "Dopo", "newVersionLater": "Dopo",
"extensions": "Estensioni", "extensions": "Estensioni",
"extensionInstallUrl": "Installa URL", "extensionInstallUrl": "Installa URL",
@@ -164,6 +164,11 @@
"autoExtract": "Estrai automaticamente gli archivi", "autoExtract": "Estrai automaticamente gli archivi",
"archivePassword": "Password archivio", "archivePassword": "Password archivio",
"archivePasswordHint": "Lasciare vuoto se non protetto da password", "archivePasswordHint": "Lasciare vuoto se non protetto da password",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Elimina l'archivio dopo l'estrazione", "deleteAfterExtract": "Elimina l'archivio dopo l'estrazione",
"extracting": "Estrazione in corso", "extracting": "Estrazione in corso",
"extractDone": "Estrazione completata", "extractDone": "Estrazione completata",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "追加", "add": "追加",
"edit": "編集", "edit": "編集",
"newVersionTitle": "新しいバージョン @version を発見する", "newVersionTitle": "新しいバージョン @version を発見する",
"newVersionUpdate": "アップデート", "newVersionUpdate": "更新",
"newVersionLater": "後で", "newVersionLater": "後で",
"extensions": "拡張機能", "extensions": "拡張機能",
"extensionInstallUrl": "インストールURL", "extensionInstallUrl": "インストールURL",
@@ -164,6 +164,11 @@
"autoExtract": "アーカイブを自動展開", "autoExtract": "アーカイブを自動展開",
"archivePassword": "アーカイブパスワード", "archivePassword": "アーカイブパスワード",
"archivePasswordHint": "パスワードなしの場合は空のままにしてください", "archivePasswordHint": "パスワードなしの場合は空のままにしてください",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "展開後にアーカイブを削除", "deleteAfterExtract": "展開後にアーカイブを削除",
"extracting": "展開中", "extracting": "展開中",
"extractDone": "展開完了", "extractDone": "展開完了",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "추가", "add": "추가",
"edit": "편집", "edit": "편집",
"newVersionTitle": "새 버전 @version 발견", "newVersionTitle": "새 버전 @version 발견",
"newVersionUpdate": "지금 업데이트", "newVersionUpdate": "업데이트",
"newVersionLater": "나중에", "newVersionLater": "나중에",
"extensions": "확장 프로그램", "extensions": "확장 프로그램",
"extensionInstallUrl": "설치 URL", "extensionInstallUrl": "설치 URL",
@@ -164,6 +164,11 @@
"autoExtract": "압축 파일 자동 압축 해제", "autoExtract": "압축 파일 자동 압축 해제",
"archivePassword": "압축 파일 비밀번호", "archivePassword": "압축 파일 비밀번호",
"archivePasswordHint": "비밀번호가 없으면 비워 두세요", "archivePasswordHint": "비밀번호가 없으면 비워 두세요",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "압축 해제 후 압축 파일 삭제", "deleteAfterExtract": "압축 해제 후 압축 파일 삭제",
"extracting": "압축 해제 중", "extracting": "압축 해제 중",
"extractDone": "압축 해제 완료", "extractDone": "압축 해제 완료",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Dodaj", "add": "Dodaj",
"edit": "Edycja", "edit": "Edycja",
"newVersionTitle": "Sprawdź aktualizację @version", "newVersionTitle": "Sprawdź aktualizację @version",
"newVersionUpdate": "Aktualizuj teraz", "newVersionUpdate": "Aktualizuj",
"newVersionLater": "Później", "newVersionLater": "Później",
"extensions": "Wtyczki", "extensions": "Wtyczki",
"extensionInstallUrl": "Instaluj z url", "extensionInstallUrl": "Instaluj z url",
@@ -164,6 +164,11 @@
"autoExtract": "Automatycznie wyodrębnij archiwa", "autoExtract": "Automatycznie wyodrębnij archiwa",
"archivePassword": "Hasło archiwum", "archivePassword": "Hasło archiwum",
"archivePasswordHint": "Pozostaw puste, jeśli nie ma hasła", "archivePasswordHint": "Pozostaw puste, jeśli nie ma hasła",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Usuń archiwum po rozpakowaniu", "deleteAfterExtract": "Usuń archiwum po rozpakowaniu",
"extracting": "Rozpakowywanie", "extracting": "Rozpakowywanie",
"extractDone": "Rozpakowywanie zakończone", "extractDone": "Rozpakowywanie zakończone",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Adicionar", "add": "Adicionar",
"edit": "Editar", "edit": "Editar",
"newVersionTitle": "Nova Versão Disponível @version", "newVersionTitle": "Nova Versão Disponível @version",
"newVersionUpdate": "Atualizar Agora", "newVersionUpdate": "Atualizar",
"newVersionLater": "Mais Tarde", "newVersionLater": "Mais Tarde",
"extensions": "Extensões", "extensions": "Extensões",
"extensionInstallUrl": "Link de Instalação", "extensionInstallUrl": "Link de Instalação",
@@ -164,6 +164,11 @@
"autoExtract": "Extrair arquivos automaticamente", "autoExtract": "Extrair arquivos automaticamente",
"archivePassword": "Senha do arquivo", "archivePassword": "Senha do arquivo",
"archivePasswordHint": "Deixar vazio se não estiver protegido por senha", "archivePasswordHint": "Deixar vazio se não estiver protegido por senha",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Excluir arquivo após extração", "deleteAfterExtract": "Excluir arquivo após extração",
"extracting": "Extraindo", "extracting": "Extraindo",
"extractDone": "Extração concluída", "extractDone": "Extração concluída",
+5
View File
@@ -164,6 +164,11 @@
"autoExtract": "Автоматически распаковать архивы", "autoExtract": "Автоматически распаковать архивы",
"archivePassword": "Пароль архива", "archivePassword": "Пароль архива",
"archivePasswordHint": "Оставить пустым, если архив не защищён паролем", "archivePasswordHint": "Оставить пустым, если архив не защищён паролем",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Удалить архив после распаковки", "deleteAfterExtract": "Удалить архив после распаковки",
"extracting": "Распаковка", "extracting": "Распаковка",
"extractDone": "Распаковка завершена", "extractDone": "Распаковка завершена",
+5
View File
@@ -178,6 +178,11 @@
"autoExtract": "Automaticky rozbaliť archívy", "autoExtract": "Automaticky rozbaliť archívy",
"archivePassword": "Heslo archívu", "archivePassword": "Heslo archívu",
"archivePasswordHint": "Nechajte prázdne, ak nie je chránený heslom", "archivePasswordHint": "Nechajte prázdne, ak nie je chránený heslom",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Po rozbalení odstrániť archívy", "deleteAfterExtract": "Po rozbalení odstrániť archívy",
"extracting": "Rozbaľovanie", "extracting": "Rozbaľovanie",
"extractDone": "Rozbalenie dokončené", "extractDone": "Rozbalenie dokončené",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "சேர்", "add": "சேர்",
"edit": "திருத்துக", "edit": "திருத்துக",
"newVersionTitle": "புதிய பதிப்பைக் கண்டறியவும் @version", "newVersionTitle": "புதிய பதிப்பைக் கண்டறியவும் @version",
"newVersionUpdate": "இப்பொழுது புதுப்பி", "newVersionUpdate": "புதுப்பிப்பு",
"newVersionLater": "பின்னர்", "newVersionLater": "பின்னர்",
"extensions": "நீட்டிப்புகள்", "extensions": "நீட்டிப்புகள்",
"extensionInstallUrl": "நிறுவப்படும் URL", "extensionInstallUrl": "நிறுவப்படும் URL",
@@ -164,6 +164,11 @@
"autoExtract": "காப்பகங்களை தானாக பிரித்தெடு", "autoExtract": "காப்பகங்களை தானாக பிரித்தெடு",
"archivePassword": "காப்பக கடவுச்சொல்", "archivePassword": "காப்பக கடவுச்சொல்",
"archivePasswordHint": "கடவுச்சொல் இல்லையென்றால் காலியாக விடவும்", "archivePasswordHint": "கடவுச்சொல் இல்லையென்றால் காலியாக விடவும்",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "பிரித்தெடுத்த பிறகு காப்பகத்தை நீக்கு", "deleteAfterExtract": "பிரித்தெடுத்த பிறகு காப்பகத்தை நீக்கு",
"extracting": "பிரித்தெடுக்கிறது", "extracting": "பிரித்தெடுக்கிறது",
"extractDone": "பிரித்தெடுத்தல் முடிந்தது", "extractDone": "பிரித்தெடுத்தல் முடிந்தது",
+6 -1
View File
@@ -99,7 +99,7 @@
"add": "Ekle", "add": "Ekle",
"edit": "Düzenle", "edit": "Düzenle",
"newVersionTitle": "Yeni sürüm @version keşfedildi", "newVersionTitle": "Yeni sürüm @version keşfedildi",
"newVersionUpdate": "Şimdi Güncelle", "newVersionUpdate": "Güncelle",
"newVersionLater": "Daha Sonra", "newVersionLater": "Daha Sonra",
"extensions": "Eklentiler", "extensions": "Eklentiler",
"extensionInstallUrl": "Kurulum URL'si", "extensionInstallUrl": "Kurulum URL'si",
@@ -164,6 +164,11 @@
"autoExtract": "Arşivleri Otomatik Çıkar", "autoExtract": "Arşivleri Otomatik Çıkar",
"archivePassword": "Arşiv Şifresi", "archivePassword": "Arşiv Şifresi",
"archivePasswordHint": "Şifre korumalı değilse boş bırakın", "archivePasswordHint": "Şifre korumalı değilse boş bırakın",
"checksumAlgorithm": "Checksum Algorithm",
"checksumHash": "Checksum Hash",
"checksumExpectedHint": "Expected hash (optional)",
"checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters",
"checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)",
"deleteAfterExtract": "Çıkardıktan sonra arşivi sil", "deleteAfterExtract": "Çıkardıktan sonra arşivi sil",
"extracting": "Çıkarılıyor", "extracting": "Çıkarılıyor",
"extractDone": "Çıkarma tamamlandı", "extractDone": "Çıkarma tamamlandı",
Loaded 50 of 60 files, more files were not shown because too many files have changed in this diff. Show more