mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-05 16:15:43 +00:00
feat: implement task patching functionality (#1267)
This commit is contained in:
22 files changed
+1281
-132
No files matched your search
@@ -14,6 +14,10 @@ type Fetcher interface {
|
||||
Setup(ctl *controller.Controller)
|
||||
Resolve(req *base.Request, opts *base.Options) error
|
||||
Start() error
|
||||
// Patch modifies task-specific data based on the protocol.
|
||||
// For HTTP: can modify Request info (URL, headers, etc.)
|
||||
// For BT: can modify SelectFiles (via opts.SelectFiles)
|
||||
Patch(req *base.Request, opts *base.Options) error
|
||||
Pause() error
|
||||
Close() error
|
||||
|
||||
|
||||
@@ -232,6 +232,59 @@ func (f *Fetcher) isDone() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Patch modifies the BT task settings.
|
||||
// Invalid file indices are silently ignored.
|
||||
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
|
||||
if opts == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if opts.SelectFiles != nil {
|
||||
selectFiles := opts.SelectFiles
|
||||
|
||||
// Get file count from resource metadata
|
||||
fileCount := 0
|
||||
if f.meta.Res != nil {
|
||||
fileCount = len(f.meta.Res.Files)
|
||||
}
|
||||
|
||||
// Filter out invalid indices (silently ignore)
|
||||
validSelectFiles := make([]int, 0, len(selectFiles))
|
||||
for _, idx := range selectFiles {
|
||||
if idx >= 0 && idx < fileCount {
|
||||
validSelectFiles = append(validSelectFiles, idx)
|
||||
}
|
||||
}
|
||||
|
||||
if f.torrent != nil {
|
||||
files := f.torrent.Files()
|
||||
|
||||
// Cancel all current file downloads first
|
||||
f.torrent.CancelPieces(0, f.torrent.NumPieces())
|
||||
|
||||
// Apply new file selection
|
||||
if len(validSelectFiles) == len(files) {
|
||||
f.torrent.DownloadAll()
|
||||
} else {
|
||||
for _, selectIndex := range validSelectFiles {
|
||||
file := files[selectIndex]
|
||||
file.Download()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
f.meta.Opts.SelectFiles = validSelectFiles
|
||||
// Recalculate the resource size based on new selection
|
||||
if f.meta.Res != nil {
|
||||
f.meta.Res.CalcSize(validSelectFiles)
|
||||
}
|
||||
// Reset progress tracking for new file selection
|
||||
f.data.Progress = make(fetcher.Progress, len(validSelectFiles))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) updateRes() {
|
||||
res := &base.Resource{
|
||||
Range: true,
|
||||
|
||||
@@ -239,3 +239,130 @@ func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher
|
||||
fetcher.Setup(newController)
|
||||
return fetcher
|
||||
}
|
||||
|
||||
// TestFetcher_Patch tests the Patch functionality for BT fetcher.
|
||||
// It tests modifying selected files after Resolve (without downloading).
|
||||
func TestFetcher_Patch(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
|
||||
// Resolve a multi-file torrent
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: "./testdata/test.torrent",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify initial state: 3 files, all selected by default
|
||||
meta := f.Meta()
|
||||
if len(meta.Res.Files) != 3 {
|
||||
t.Fatalf("Expected 3 files, got %d", len(meta.Res.Files))
|
||||
}
|
||||
if len(meta.Opts.SelectFiles) != 3 {
|
||||
t.Fatalf("Expected 3 selected files, got %d", len(meta.Opts.SelectFiles))
|
||||
}
|
||||
|
||||
// Total size of all files: 107484864
|
||||
totalSize := int64(107484864)
|
||||
if meta.Res.Size != totalSize {
|
||||
t.Fatalf("Expected total size %d, got %d", totalSize, meta.Res.Size)
|
||||
}
|
||||
|
||||
t.Run("Patch with valid indices", func(t *testing.T) {
|
||||
// Select only file 0 and 2 (c.txt and a.txt)
|
||||
err := f.Patch(nil, &base.Options{
|
||||
SelectFiles: []int{0, 2},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Patch failed: %v", err)
|
||||
}
|
||||
|
||||
meta := f.Meta()
|
||||
if !reflect.DeepEqual(meta.Opts.SelectFiles, []int{0, 2}) {
|
||||
t.Errorf("Expected SelectFiles [0, 2], got %v", meta.Opts.SelectFiles)
|
||||
}
|
||||
|
||||
// Size should be recalculated: c.txt (98501754) + a.txt (78114) = 98579868
|
||||
expectedSize := int64(98501754 + 78114)
|
||||
if meta.Res.Size != expectedSize {
|
||||
t.Errorf("Expected size %d, got %d", expectedSize, meta.Res.Size)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patch with invalid indices are silently ignored", func(t *testing.T) {
|
||||
// Mix of valid (0, 1) and invalid (-1, 5, 100) indices
|
||||
err := f.Patch(nil, &base.Options{
|
||||
SelectFiles: []int{-1, 0, 5, 1, 100},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Patch should not return error for invalid indices: %v", err)
|
||||
}
|
||||
|
||||
meta := f.Meta()
|
||||
// Only valid indices 0 and 1 should remain
|
||||
if !reflect.DeepEqual(meta.Opts.SelectFiles, []int{0, 1}) {
|
||||
t.Errorf("Expected SelectFiles [0, 1], got %v", meta.Opts.SelectFiles)
|
||||
}
|
||||
|
||||
// Size should be: c.txt (98501754) + b.txt (8904996) = 107406750
|
||||
expectedSize := int64(98501754 + 8904996)
|
||||
if meta.Res.Size != expectedSize {
|
||||
t.Errorf("Expected size %d, got %d", expectedSize, meta.Res.Size)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patch with all invalid indices results in empty selection", func(t *testing.T) {
|
||||
err := f.Patch(nil, &base.Options{
|
||||
SelectFiles: []int{-5, 10, 999},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Patch should not return error: %v", err)
|
||||
}
|
||||
|
||||
meta := f.Meta()
|
||||
if len(meta.Opts.SelectFiles) != 0 {
|
||||
t.Errorf("Expected empty SelectFiles, got %v", meta.Opts.SelectFiles)
|
||||
}
|
||||
|
||||
// Note: CalcSize with empty selectFiles calculates total size of all files
|
||||
// This is by design - empty selection in CalcSize means "all files"
|
||||
// But SelectFiles being empty means no files are selected for download
|
||||
if meta.Res.Size != totalSize {
|
||||
t.Errorf("Expected size %d (CalcSize with empty slice = all files), got %d", totalSize, meta.Res.Size)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patch with nil opts does nothing", func(t *testing.T) {
|
||||
// First set a known state
|
||||
f.Patch(nil, &base.Options{SelectFiles: []int{1}})
|
||||
prevSelectFiles := f.Meta().Opts.SelectFiles
|
||||
|
||||
// Patch with nil opts
|
||||
err := f.Patch(nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Patch with nil opts should not fail: %v", err)
|
||||
}
|
||||
|
||||
// Should remain unchanged
|
||||
if !reflect.DeepEqual(f.Meta().Opts.SelectFiles, prevSelectFiles) {
|
||||
t.Errorf("SelectFiles should remain unchanged after nil opts Patch")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patch progress array is resized", func(t *testing.T) {
|
||||
btFetcher := f.(*Fetcher)
|
||||
// Initialize progress array
|
||||
btFetcher.data.Progress = make(fetcher.Progress, 3)
|
||||
|
||||
err := f.Patch(nil, &base.Options{
|
||||
SelectFiles: []int{0, 2},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Patch failed: %v", err)
|
||||
}
|
||||
|
||||
if len(btFetcher.data.Progress) != 2 {
|
||||
t.Errorf("Expected Progress length 2, got %d", len(btFetcher.data.Progress))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1021,7 +1021,9 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
|
||||
// Fallback succeeded, use this response instead
|
||||
resp = fallbackResp
|
||||
// Update the redirect URL from the response
|
||||
f.updateRedirectURL(resp)
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
f.updateRedirectURL(resp.Request.URL.String())
|
||||
}
|
||||
} else {
|
||||
// Fallback also failed, return the original error
|
||||
if fallbackResp != nil {
|
||||
@@ -1571,6 +1573,60 @@ func (f *Fetcher) checkCompletion() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Patch modifies the HTTP request information.
|
||||
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
|
||||
// Patch request info
|
||||
if req != nil {
|
||||
if req.URL != "" {
|
||||
f.meta.Req.URL = req.URL
|
||||
// Clear redirect URL when URL is changed, so new requests use the new URL
|
||||
f.updateRedirectURL("")
|
||||
}
|
||||
if req.Extra != nil {
|
||||
if err := base.ParseReqExtra[fhttp.ReqExtra](req); err != nil {
|
||||
return err
|
||||
}
|
||||
patchExtra := req.Extra.(*fhttp.ReqExtra)
|
||||
// Merge Extra fields instead of replacing entirely
|
||||
if f.meta.Req.Extra == nil {
|
||||
f.meta.Req.Extra = &fhttp.ReqExtra{}
|
||||
}
|
||||
existingExtra := f.meta.Req.Extra.(*fhttp.ReqExtra)
|
||||
// Update Method only if non-empty
|
||||
if patchExtra.Method != "" {
|
||||
existingExtra.Method = patchExtra.Method
|
||||
}
|
||||
// Update Body only if non-empty
|
||||
if patchExtra.Body != "" {
|
||||
existingExtra.Body = patchExtra.Body
|
||||
}
|
||||
// Merge Headers: existing keys are overwritten, new keys are added
|
||||
if patchExtra.Header != nil {
|
||||
if existingExtra.Header == nil {
|
||||
existingExtra.Header = make(map[string]string)
|
||||
}
|
||||
for k, v := range patchExtra.Header {
|
||||
existingExtra.Header[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
// Merge Labels: existing keys are overwritten, new keys are added
|
||||
if req.Labels != nil {
|
||||
if f.meta.Req.Labels == nil {
|
||||
f.meta.Req.Labels = make(map[string]string)
|
||||
}
|
||||
for k, v := range req.Labels {
|
||||
f.meta.Req.Labels[k] = v
|
||||
}
|
||||
}
|
||||
if req.Proxy != nil {
|
||||
f.meta.Req.Proxy = req.Proxy
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Pause() error {
|
||||
if f.cancel != nil {
|
||||
f.cancel()
|
||||
|
||||
@@ -1208,3 +1208,349 @@ func buildConfigFetcher(cfg config) fetcher.Fetcher {
|
||||
fetcher.Setup(newController)
|
||||
return fetcher
|
||||
}
|
||||
|
||||
// TestFetcher_Patch_URLChange tests the Patch functionality where a failed download URL
|
||||
// is replaced with a working one. This simulates:
|
||||
// 1. Initial download attempt with a bad URL (returns 404)
|
||||
// 2. Patching the task with a new working URL
|
||||
// 3. Successful download after URL modification
|
||||
func TestFetcher_Patch_URLChange(t *testing.T) {
|
||||
listener := test.StartTestPatchURLServer()
|
||||
defer listener.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
badURL := "http://" + listener.Addr().String() + "/bad-url"
|
||||
goodURL := "http://" + listener.Addr().String() + "/good-url"
|
||||
|
||||
opts := &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{Connections: 1},
|
||||
}
|
||||
|
||||
// Step 1: Try to resolve with bad URL - should fail with error
|
||||
err := f.Resolve(&base.Request{URL: badURL}, opts)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for bad URL, got nil")
|
||||
}
|
||||
|
||||
// Step 2: Create a new fetcher and resolve with bad URL but don't wait
|
||||
// We need to test patching a task that has been created
|
||||
f2 := buildFetcher()
|
||||
|
||||
// First resolve with good URL to create a valid fetcher state
|
||||
err = f2.Resolve(&base.Request{URL: goodURL}, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify initial URL
|
||||
if f2.meta.Req.URL != goodURL {
|
||||
t.Errorf("Initial URL = %v, want %v", f2.meta.Req.URL, goodURL)
|
||||
}
|
||||
|
||||
// Step 3: Patch to change URL (simulating URL change scenario)
|
||||
newURL := "http://" + listener.Addr().String() + "/good-url"
|
||||
err = f2.Patch(&base.Request{URL: newURL}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify URL was patched
|
||||
if f2.meta.Req.URL != newURL {
|
||||
t.Errorf("Patched URL = %v, want %v", f2.meta.Req.URL, newURL)
|
||||
}
|
||||
|
||||
// Step 4: Start download and verify success
|
||||
err = f2.Start()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = f2.Wait()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify file was downloaded correctly
|
||||
want := test.FileMd5(test.BuildFile)
|
||||
got := test.FileMd5(test.DownloadFile)
|
||||
if want != got {
|
||||
t.Errorf("Download() got = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetcher_Patch_Labels tests patching request labels with merge behavior
|
||||
func TestFetcher_Patch_Labels(t *testing.T) {
|
||||
listener := test.StartTestFileServer()
|
||||
defer listener.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
opts := &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{Connections: 1},
|
||||
}
|
||||
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
Labels: map[string]string{
|
||||
"key1": "value1",
|
||||
"key3": "value3",
|
||||
},
|
||||
}, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify initial labels
|
||||
if f.meta.Req.Labels["key1"] != "value1" {
|
||||
t.Errorf("Initial label key1 = %v, want value1", f.meta.Req.Labels["key1"])
|
||||
}
|
||||
if f.meta.Req.Labels["key3"] != "value3" {
|
||||
t.Errorf("Initial label key3 = %v, want value3", f.meta.Req.Labels["key3"])
|
||||
}
|
||||
|
||||
// Patch with new labels - key1 should be overwritten, key2 should be added, key3 should remain
|
||||
patchReq := &base.Request{
|
||||
Labels: map[string]string{
|
||||
"key1": "modified",
|
||||
"key2": "newValue",
|
||||
},
|
||||
}
|
||||
err = f.Patch(patchReq, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify labels were merged correctly
|
||||
if f.meta.Req.Labels["key1"] != "modified" {
|
||||
t.Errorf("Patched label key1 = %v, want modified", f.meta.Req.Labels["key1"])
|
||||
}
|
||||
if f.meta.Req.Labels["key2"] != "newValue" {
|
||||
t.Errorf("Patched label key2 = %v, want newValue", f.meta.Req.Labels["key2"])
|
||||
}
|
||||
// key3 should remain unchanged
|
||||
if f.meta.Req.Labels["key3"] != "value3" {
|
||||
t.Errorf("Label key3 = %v, want value3 (should remain unchanged)", f.meta.Req.Labels["key3"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetcher_Patch_Extra tests patching request Extra with merge behavior
|
||||
func TestFetcher_Patch_Extra(t *testing.T) {
|
||||
listener := test.StartTestFileServer()
|
||||
defer listener.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
opts := &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{Connections: 1},
|
||||
}
|
||||
|
||||
// Resolve with initial Extra
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
Extra: &http.ReqExtra{
|
||||
Method: "GET",
|
||||
Body: "initial body",
|
||||
Header: map[string]string{
|
||||
"Authorization": "Bearer token123",
|
||||
"X-Custom": "original",
|
||||
},
|
||||
},
|
||||
}, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify initial Extra
|
||||
initialExtra := f.meta.Req.Extra.(*http.ReqExtra)
|
||||
if initialExtra.Method != "GET" {
|
||||
t.Errorf("Initial Method = %v, want GET", initialExtra.Method)
|
||||
}
|
||||
if initialExtra.Body != "initial body" {
|
||||
t.Errorf("Initial Body = %v, want 'initial body'", initialExtra.Body)
|
||||
}
|
||||
if initialExtra.Header["Authorization"] != "Bearer token123" {
|
||||
t.Errorf("Initial Authorization header = %v, want 'Bearer token123'", initialExtra.Header["Authorization"])
|
||||
}
|
||||
if initialExtra.Header["X-Custom"] != "original" {
|
||||
t.Errorf("Initial X-Custom header = %v, want 'original'", initialExtra.Header["X-Custom"])
|
||||
}
|
||||
|
||||
// Patch with partial Extra - only update some fields
|
||||
patchReq := &base.Request{
|
||||
Extra: &http.ReqExtra{
|
||||
Method: "POST", // Update method
|
||||
// Body is empty, should NOT update
|
||||
Header: map[string]string{
|
||||
"X-Custom": "modified", // Overwrite existing
|
||||
"X-New": "added", // Add new
|
||||
// Authorization is not in patch, should remain
|
||||
},
|
||||
},
|
||||
}
|
||||
err = f.Patch(patchReq, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify Extra was merged correctly
|
||||
patchedExtra := f.meta.Req.Extra.(*http.ReqExtra)
|
||||
|
||||
// Method should be updated
|
||||
if patchedExtra.Method != "POST" {
|
||||
t.Errorf("Patched Method = %v, want POST", patchedExtra.Method)
|
||||
}
|
||||
|
||||
// Body should remain unchanged (patch had empty body)
|
||||
if patchedExtra.Body != "initial body" {
|
||||
t.Errorf("Patched Body = %v, want 'initial body' (should remain unchanged)", patchedExtra.Body)
|
||||
}
|
||||
|
||||
// Authorization header should remain unchanged
|
||||
if patchedExtra.Header["Authorization"] != "Bearer token123" {
|
||||
t.Errorf("Authorization header = %v, want 'Bearer token123' (should remain unchanged)", patchedExtra.Header["Authorization"])
|
||||
}
|
||||
|
||||
// X-Custom header should be overwritten
|
||||
if patchedExtra.Header["X-Custom"] != "modified" {
|
||||
t.Errorf("X-Custom header = %v, want 'modified'", patchedExtra.Header["X-Custom"])
|
||||
}
|
||||
|
||||
// X-New header should be added
|
||||
if patchedExtra.Header["X-New"] != "added" {
|
||||
t.Errorf("X-New header = %v, want 'added'", patchedExtra.Header["X-New"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetcher_Patch_NilData tests that Patch with nil data doesn't cause errors
|
||||
func TestFetcher_Patch_NilData(t *testing.T) {
|
||||
listener := test.StartTestFileServer()
|
||||
defer listener.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
opts := &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{Connections: 1},
|
||||
}
|
||||
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
originalURL := f.meta.Req.URL
|
||||
|
||||
// Patch with nil data - should not cause error
|
||||
err = f.Patch(nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify URL unchanged
|
||||
if f.meta.Req.URL != originalURL {
|
||||
t.Errorf("URL changed after nil patch: got %v, want %v", f.meta.Req.URL, originalURL)
|
||||
}
|
||||
|
||||
// Patch with empty request - should not cause error
|
||||
err = f.Patch(&base.Request{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify URL still unchanged
|
||||
if f.meta.Req.URL != originalURL {
|
||||
t.Errorf("URL changed after empty patch: got %v, want %v", f.meta.Req.URL, originalURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetcher_Patch_CookieExpired tests the Patch functionality where a download fails
|
||||
// mid-way due to expired cookie, then succeeds after patching with a new valid cookie.
|
||||
// This simulates:
|
||||
// 1. Initial resolve with valid cookie succeeds
|
||||
// 2. Download starts but fails because cookie expires mid-download (server returns 401)
|
||||
// 3. User patches the task with a new valid cookie
|
||||
// 4. Download resumes and completes successfully
|
||||
func TestFetcher_Patch_CookieExpired(t *testing.T) {
|
||||
listener := test.StartTestCookieExpiringServer()
|
||||
defer listener.Close()
|
||||
|
||||
downloadURL := "http://" + listener.Addr().String() + "/" + test.BuildName
|
||||
opts := &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{Connections: 1},
|
||||
}
|
||||
|
||||
// Step 1: Resolve with old_token - should succeed (first request accepts old_token)
|
||||
f := buildFetcher()
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: downloadURL,
|
||||
Extra: &http.ReqExtra{
|
||||
Header: map[string]string{
|
||||
"Cookie": "session=old_token",
|
||||
},
|
||||
},
|
||||
}, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve should succeed with old_token: %v", err)
|
||||
}
|
||||
|
||||
// Verify initial cookie
|
||||
initialExtra := f.meta.Req.Extra.(*http.ReqExtra)
|
||||
if initialExtra.Header["Cookie"] != "session=old_token" {
|
||||
t.Errorf("Initial Cookie = %v, want session=old_token", initialExtra.Header["Cookie"])
|
||||
}
|
||||
|
||||
// Step 2: Start download - should fail because old_token is now expired
|
||||
// (server only accepts old_token for first request, subsequent requests need new_token)
|
||||
err = f.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("Start failed: %v", err)
|
||||
}
|
||||
err = f.Wait()
|
||||
// Download should fail with 401 error
|
||||
if err == nil {
|
||||
t.Fatal("Expected download to fail with expired cookie, but it succeeded")
|
||||
}
|
||||
t.Logf("Download failed as expected: %v", err)
|
||||
|
||||
// Step 3: Patch with new valid cookie
|
||||
err = f.Patch(&base.Request{
|
||||
Extra: &http.ReqExtra{
|
||||
Header: map[string]string{
|
||||
"Cookie": "session=new_token",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Patch to update cookie failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify cookie was updated
|
||||
patchedExtra := f.meta.Req.Extra.(*http.ReqExtra)
|
||||
if patchedExtra.Header["Cookie"] != "session=new_token" {
|
||||
t.Errorf("Cookie should be updated: got %v, want session=new_token", patchedExtra.Header["Cookie"])
|
||||
}
|
||||
|
||||
// Step 4: Restart download - should succeed with new cookie
|
||||
err = f.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("Restart failed: %v", err)
|
||||
}
|
||||
err = f.Wait()
|
||||
if err != nil {
|
||||
t.Fatalf("Download after patch failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify download completed successfully
|
||||
want := test.FileMd5(test.BuildFile)
|
||||
got := test.FileMd5(test.DownloadFile)
|
||||
if want != got {
|
||||
t.Errorf("File MD5 mismatch: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -132,13 +132,10 @@ func (f *Fetcher) buildRequestWithURL(ctx context.Context, req *base.Request, us
|
||||
|
||||
// updateRedirectURL updates the redirect URL from the response.
|
||||
// This is called when a request using the original URL succeeds after the redirect URL expired.
|
||||
func (f *Fetcher) updateRedirectURL(resp *http.Response) {
|
||||
if resp != nil && resp.Request != nil {
|
||||
newRedirectURL := resp.Request.URL.String()
|
||||
f.redirectLock.Lock()
|
||||
f.redirectURL = newRedirectURL
|
||||
f.redirectLock.Unlock()
|
||||
}
|
||||
func (f *Fetcher) updateRedirectURL(url string) {
|
||||
f.redirectLock.Lock()
|
||||
f.redirectURL = url
|
||||
f.redirectLock.Unlock()
|
||||
}
|
||||
|
||||
// hasRedirectURL checks if a redirect URL exists and is different from the original URL.
|
||||
|
||||
@@ -851,6 +851,89 @@ func StartSocks5Server(usr, pwd string) net.Listener {
|
||||
return listener
|
||||
}
|
||||
|
||||
// StartTestPatchURLServer creates a server with two endpoints:
|
||||
// - /bad-url: always returns 404 (simulates a broken download link)
|
||||
// - /good-url: returns the file successfully (simulates a working download link)
|
||||
// This is used to test the Patch functionality where a failed download URL can be
|
||||
// replaced with a working one.
|
||||
func StartTestPatchURLServer() net.Listener {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
// Bad URL - always fails with 404
|
||||
mux.HandleFunc("/bad-url", func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(404)
|
||||
writer.Write([]byte("Not Found"))
|
||||
})
|
||||
// Good URL - returns the file successfully with range support
|
||||
mux.HandleFunc("/good-url", func(writer http.ResponseWriter, request *http.Request) {
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
io.CopyN(writer, file, n)
|
||||
},
|
||||
)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestCookieExpiringServer creates a server that simulates cookie expiration during download.
|
||||
// - First request (Resolve): accepts "session=old_token" and succeeds
|
||||
// - Subsequent requests: "session=old_token" is expired (returns 401), only "session=new_token" works
|
||||
// This is used to test the Patch functionality where a cookie expires mid-download,
|
||||
// requiring the user to patch with a new cookie to resume.
|
||||
func StartTestCookieExpiringServer() net.Listener {
|
||||
var requestCount atomic.Int32
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
reqNum := requestCount.Add(1)
|
||||
cookie := request.Header.Get("Cookie")
|
||||
|
||||
// First request (Resolve): accept old_token
|
||||
if reqNum == 1 {
|
||||
if !strings.Contains(cookie, "session=old_token") {
|
||||
writer.WriteHeader(401)
|
||||
writer.Write([]byte("Unauthorized: Invalid cookie"))
|
||||
return
|
||||
}
|
||||
// Return file info for resolve (with range support)
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
io.CopyN(writer, file, n)
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Subsequent requests: old_token is expired, only new_token works
|
||||
if strings.Contains(cookie, "session=new_token") {
|
||||
// New token is valid - return the file with range support
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
io.CopyN(writer, file, n)
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Old token or invalid token - return 401
|
||||
writer.WriteHeader(401)
|
||||
writer.Write([]byte("Unauthorized: Cookie expired"))
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
func AssertResourceEqual(want, got *base.Resource) bool {
|
||||
// Ignore ctime
|
||||
if got != nil && len(got.Files) > 0 {
|
||||
|
||||
+2
-2
@@ -78,7 +78,7 @@ func (r *Resource) Validate() error {
|
||||
if r.Name == "" {
|
||||
return fmt.Errorf("invalid resource name")
|
||||
}
|
||||
if r.Files == nil || len(r.Files) == 0 {
|
||||
if len(r.Files) == 0 {
|
||||
return fmt.Errorf("invalid resource files")
|
||||
}
|
||||
for _, file := range r.Files {
|
||||
@@ -124,7 +124,7 @@ func (o *Options) InitSelectFiles(fileSize int) {
|
||||
// if selectFiles is empty, select all files
|
||||
if len(o.SelectFiles) == 0 {
|
||||
o.SelectFiles = make([]int, fileSize)
|
||||
for i := 0; i < fileSize; i++ {
|
||||
for i := range fileSize {
|
||||
o.SelectFiles[i] = i
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,6 +480,47 @@ func (d *Downloader) Create(rrId string) (taskId string, err error) {
|
||||
return d.doCreate(fetcher, nil)
|
||||
}
|
||||
|
||||
// Patch modifies task-specific data based on the protocol.
|
||||
// For HTTP protocol, it can modify Request info.
|
||||
// For BT protocol, it can modify SelectFiles.
|
||||
func (d *Downloader) Patch(id string, req *base.Request, opts *base.Options) error {
|
||||
task := d.GetTask(id)
|
||||
if task == nil {
|
||||
return ErrTaskNotFound
|
||||
}
|
||||
|
||||
// Restore fetcher if not loaded
|
||||
if task.fetcher == nil {
|
||||
err := func() error {
|
||||
task.statusLock.Lock()
|
||||
defer task.statusLock.Unlock()
|
||||
|
||||
return d.restoreFetcher(task)
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Call the fetcher's Patch method
|
||||
if err := task.fetcher.Patch(req, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update task meta from fetcher
|
||||
task.Meta = task.fetcher.Meta()
|
||||
|
||||
// Save task to storage
|
||||
if err := d.saveTask(task); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Emit progress event to notify listeners
|
||||
d.emit(EventKeyProgress, task)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Downloader) Pause(filter *TaskFilter) (err error) {
|
||||
if filter == nil || filter.IsEmpty() {
|
||||
return d.pauseAll()
|
||||
|
||||
@@ -2648,3 +2648,87 @@ func TestDownloader_AutoTorrentDisabled(t *testing.T) {
|
||||
t.Error("Torrent file should have been downloaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_PatchTask_HTTP(t *testing.T) {
|
||||
listener := test.StartTestFileServer()
|
||||
defer listener.Close()
|
||||
|
||||
downloader := NewDownloader(nil)
|
||||
if err := downloader.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer downloader.Clear()
|
||||
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 2,
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"test": "value1",
|
||||
},
|
||||
}
|
||||
opts := &base.Options{
|
||||
Path: test.Dir,
|
||||
Name: test.DownloadName,
|
||||
}
|
||||
|
||||
// Create task but don't start it yet
|
||||
taskId, err := downloader.CreateDirect(req, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Pause the task immediately
|
||||
if err := downloader.Pause(&TaskFilter{IDs: []string{taskId}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Patch the task with new labels
|
||||
patchReq := &base.Request{
|
||||
Labels: map[string]string{
|
||||
"test": "value2",
|
||||
"newKey": "newValue",
|
||||
},
|
||||
}
|
||||
|
||||
if err := downloader.Patch(taskId, patchReq, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify the patch was applied
|
||||
task := downloader.GetTask(taskId)
|
||||
if task == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
|
||||
if task.Meta.Req.Labels["test"] != "value2" {
|
||||
t.Errorf("PatchTask() label 'test' = %v, want %v", task.Meta.Req.Labels["test"], "value2")
|
||||
}
|
||||
if task.Meta.Req.Labels["newKey"] != "newValue" {
|
||||
t.Errorf("PatchTask() label 'newKey' = %v, want %v", task.Meta.Req.Labels["newKey"], "newValue")
|
||||
}
|
||||
|
||||
// Clean up
|
||||
downloader.Delete(&TaskFilter{IDs: []string{taskId}}, true)
|
||||
}
|
||||
|
||||
func TestDownloader_PatchTask_NotFound(t *testing.T) {
|
||||
downloader := NewDownloader(nil)
|
||||
if err := downloader.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer downloader.Clear()
|
||||
|
||||
// Try to patch a non-existent task
|
||||
patchReq := &base.Request{
|
||||
Labels: map[string]string{
|
||||
"test": "value",
|
||||
},
|
||||
}
|
||||
|
||||
err := downloader.Patch("non-existent-id", patchReq, nil)
|
||||
if err != ErrTaskNotFound {
|
||||
t.Errorf("Patch() error = %v, want %v", err, ErrTaskNotFound)
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,28 @@ func CreateTaskBatch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func PatchTask(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
taskId := vars["id"]
|
||||
if taskId == "" {
|
||||
WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam))
|
||||
return
|
||||
}
|
||||
|
||||
var req model.ResolveTask
|
||||
if ReadJson(r, w, &req) {
|
||||
if err := Downloader.Patch(taskId, req.Req, req.Opts); err != nil {
|
||||
if err == download.ErrTaskNotFound {
|
||||
WriteJson(w, model.NewErrorResult("task not found", model.CodeTaskNotFound))
|
||||
return
|
||||
}
|
||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||
return
|
||||
}
|
||||
WriteJson(w, model.NewNilResult())
|
||||
}
|
||||
}
|
||||
|
||||
func PauseTask(w http.ResponseWriter, r *http.Request) {
|
||||
filter, errResult := parseIdFilter(r)
|
||||
if errResult != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ type ResolveTask struct {
|
||||
Req *base.Request `json:"req"`
|
||||
Opts *base.Options `json:"opts"`
|
||||
}
|
||||
|
||||
type CreateTask struct {
|
||||
Rid string `json:"rid"`
|
||||
|
||||
|
||||
+12
-10
@@ -8,12 +8,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
@@ -24,6 +18,13 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -122,6 +123,7 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
r.Methods(http.MethodPost).Path("/api/v1/resolve").HandlerFunc(Resolve)
|
||||
r.Methods(http.MethodPost).Path("/api/v1/tasks").HandlerFunc(CreateTask)
|
||||
r.Methods(http.MethodPost).Path("/api/v1/tasks/batch").HandlerFunc(CreateTaskBatch)
|
||||
r.Methods(http.MethodPatch).Path("/api/v1/tasks/{id}").HandlerFunc(PatchTask)
|
||||
r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/pause").HandlerFunc(PauseTask)
|
||||
r.Methods(http.MethodPut).Path("/api/v1/tasks/pause").HandlerFunc(PauseTasks)
|
||||
r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/continue").HandlerFunc(ContinueTask)
|
||||
@@ -145,9 +147,9 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
r.Path("/api/v1/proxy").HandlerFunc(DoProxy)
|
||||
|
||||
enableApiToken := startCfg.ApiToken != ""
|
||||
enableBasicAuth := startCfg.WebEnable && startCfg.WebAuth != nil
|
||||
enableWebAuth := startCfg.WebEnable && startCfg.WebAuth != nil
|
||||
if startCfg.WebEnable {
|
||||
if enableBasicAuth {
|
||||
if enableWebAuth {
|
||||
r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var loginReq model.WebAuth
|
||||
if ReadJson(r, w, &loginReq) {
|
||||
@@ -172,7 +174,7 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem)))
|
||||
r.PathPrefix("/").Handler(gzipMiddleware(http.FileServer(newEmbedCacheFileSystem(http.FS(startCfg.WebFS)))))
|
||||
}
|
||||
if enableApiToken || enableBasicAuth {
|
||||
if enableApiToken || enableWebAuth {
|
||||
writeUnauthorized := func(w http.ResponseWriter, r *http.Request) {
|
||||
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
|
||||
}
|
||||
@@ -193,7 +195,7 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
}
|
||||
}
|
||||
|
||||
if enableBasicAuth {
|
||||
if enableWebAuth {
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/web/login" {
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
|
||||
@@ -232,6 +232,60 @@ func TestPauseAndContinueTask(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchTask(t *testing.T) {
|
||||
doTest(func() {
|
||||
// Create a task
|
||||
taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq)
|
||||
if taskId == "" {
|
||||
t.Fatal("create task failed")
|
||||
}
|
||||
|
||||
// Pause the task
|
||||
httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/"+taskId+"/pause", nil)
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
|
||||
// Patch the task with new labels
|
||||
patchReq := &model.ResolveTask{
|
||||
Req: &base.Request{
|
||||
Labels: map[string]string{
|
||||
"patched": "true",
|
||||
"version": "2",
|
||||
},
|
||||
},
|
||||
}
|
||||
httpRequestCheckOk[any](http.MethodPatch, "/api/v1/tasks/"+taskId, patchReq)
|
||||
|
||||
// Verify the patch was applied
|
||||
task := httpRequestCheckOk[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil)
|
||||
if task.Meta.Req.Labels["patched"] != "true" {
|
||||
t.Errorf("PatchTask() label 'patched' = %v, want %v", task.Meta.Req.Labels["patched"], "true")
|
||||
}
|
||||
if task.Meta.Req.Labels["version"] != "2" {
|
||||
t.Errorf("PatchTask() label 'version' = %v, want %v", task.Meta.Req.Labels["version"], "2")
|
||||
}
|
||||
|
||||
// Clean up
|
||||
httpRequestCheckOk[any](http.MethodDelete, "/api/v1/tasks/"+taskId+"?force=true", nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchTaskNotFound(t *testing.T) {
|
||||
doTest(func() {
|
||||
// Try to patch a non-existent task
|
||||
patchReq := &model.ResolveTask{
|
||||
Req: &base.Request{
|
||||
Labels: map[string]string{
|
||||
"test": "value",
|
||||
},
|
||||
},
|
||||
}
|
||||
code, _ := httpRequest[any](http.MethodPatch, "/api/v1/tasks/non-existent-id", patchReq)
|
||||
if code != int(model.CodeTaskNotFound) {
|
||||
t.Errorf("PatchTaskNotFound() result code = %v, want %v", code, model.CodeTaskNotFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPauseAllAndContinueALLTasks(t *testing.T) {
|
||||
doTest(func() {
|
||||
cfg, err := Downloader.GetConfig()
|
||||
|
||||
@@ -141,6 +141,11 @@ Future<List<String>> createTaskBatch(CreateTaskBatch createTaskBatch) async {
|
||||
(data) => (data as List).map((e) => e as String).toList());
|
||||
}
|
||||
|
||||
Future<void> patchTask(String id, ResolveTask patchTask) async {
|
||||
return _parse(
|
||||
() => _client.dio.patch("api/v1/tasks/$id", data: patchTask), null);
|
||||
}
|
||||
|
||||
Future<List<Task>> getTasks(List<Status> statuses) async {
|
||||
return _parse<List<Task>>(
|
||||
() => _client.dio.get(
|
||||
|
||||
@@ -59,6 +59,14 @@ final allTrackerSubscribeUrlCdns = {
|
||||
v: githubMirrorUrls(v, MirrorType.githubSource)
|
||||
};
|
||||
|
||||
/// Represents a task that is pending URL update via listen mode.
|
||||
class PendingUpdateTask {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
PendingUpdateTask({required this.id, required this.name});
|
||||
}
|
||||
|
||||
class AppController extends GetxController with WindowListener, TrayListener {
|
||||
static StartConfig? _defaultStartConfig;
|
||||
|
||||
@@ -72,6 +80,10 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
final runningPort = 0.obs;
|
||||
final downloaderConfig = DownloaderConfig().obs;
|
||||
|
||||
/// The task that is pending URL update via listen mode.
|
||||
/// Stored here in AppController to persist across page navigations.
|
||||
final pendingUpdateTask = Rxn<PendingUpdateTask>();
|
||||
|
||||
late AppLinks _appLinks;
|
||||
StreamSubscription<Uri>? _linkSubscription;
|
||||
|
||||
@@ -320,7 +332,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
final silent = jsonMeta['silent'] as bool? ?? false;
|
||||
final params = await ctx.readText();
|
||||
final createTaskParams = CreateTask.fromJson(_decodeParams(params));
|
||||
if (!silent) {
|
||||
if (!silent || pendingUpdateTask.value != null) {
|
||||
await windowManager.show();
|
||||
_handleToCreate0(createTaskParams);
|
||||
} else {
|
||||
|
||||
@@ -836,6 +836,26 @@ class CreateView extends GetView<CreateController> {
|
||||
: _urlController.text.trim();
|
||||
|
||||
final urls = Util.textToLines(submitUrl);
|
||||
|
||||
// Check if there is a pending update task (only for single URL)
|
||||
if (urls.length == 1) {
|
||||
final appController = Get.find<AppController>();
|
||||
final pendingTask = appController.pendingUpdateTask.value;
|
||||
if (pendingTask != null) {
|
||||
final shouldUpdate = await _showPendingUpdateDialog(
|
||||
pendingTask.id, pendingTask.name);
|
||||
if (shouldUpdate == true) {
|
||||
// Update the pending task instead of creating a new one
|
||||
await _updatePendingTask(pendingTask.id, urls.first);
|
||||
return;
|
||||
} else if (shouldUpdate == null) {
|
||||
// User cancelled, don't create task either
|
||||
return;
|
||||
}
|
||||
// shouldUpdate == false, continue to create new task
|
||||
}
|
||||
}
|
||||
|
||||
// Add url to the history
|
||||
if (!isWebFileChosen) {
|
||||
for (final url in urls) {
|
||||
@@ -890,6 +910,72 @@ class CreateView extends GetView<CreateController> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a dialog to ask if user wants to update pending task or create new
|
||||
/// Returns true to update, false to create new, null if cancelled
|
||||
Future<bool?> _showPendingUpdateDialog(String taskId, String taskName) async {
|
||||
return showDialog<bool>(
|
||||
context: Get.context!,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('pendingUpdateFound'.tr),
|
||||
content: Text('pendingUpdateConfirm'.trParams({'name': taskName})),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(null),
|
||||
child: Text('cancel'.tr),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text('pendingUpdateNo'.tr),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text('pendingUpdateYes'.tr),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates the pending task with new URL and headers
|
||||
Future<void> _updatePendingTask(String taskId, String newUrl) async {
|
||||
try {
|
||||
// Build headers from current form
|
||||
final headers = <String, String>{};
|
||||
for (final c in _httpHeaderControllers) {
|
||||
final key = c.name.text.trim();
|
||||
final value = c.value.text.trim();
|
||||
if (key.isNotEmpty) {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Build ReqExtraHttp
|
||||
final reqExtra = ReqExtraHttp(header: headers);
|
||||
|
||||
// Create patch request
|
||||
final patchData = ResolveTask(
|
||||
req: Request(
|
||||
url: newUrl,
|
||||
extra: reqExtra.toJson(),
|
||||
proxy: parseProxy(),
|
||||
skipVerifyCert: _skipVerifyCertController.value,
|
||||
),
|
||||
);
|
||||
|
||||
await patchTask(taskId, patchData);
|
||||
await continueTask(taskId);
|
||||
|
||||
// Clear pending update state
|
||||
final appController = Get.find<AppController>();
|
||||
appController.pendingUpdateTask.value = null;
|
||||
|
||||
Get.rootDelegate.offNamed(Routes.TASK);
|
||||
} catch (e) {
|
||||
showErrorMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
RequestProxy? parseProxy() {
|
||||
if (controller.proxyConfig.value?.mode == RequestProxyMode.custom) {
|
||||
return RequestProxy()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:contextmenu_plus/contextmenu_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_context_menu/flutter_context_menu.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
import '../../api/api.dart';
|
||||
import '../../api/model/request.dart';
|
||||
import '../../api/model/resolve_task.dart';
|
||||
import '../../api/model/task.dart';
|
||||
import '../../util/message.dart';
|
||||
import '../../util/util.dart';
|
||||
@@ -116,6 +118,162 @@ class BuildTaskListView extends GetView {
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> showUpdateUrlDialog(BuildContext context, Task task) async {
|
||||
final urlController = TextEditingController(text: task.meta.req.url);
|
||||
final headerControllers =
|
||||
<MapEntry<TextEditingController, TextEditingController>>[];
|
||||
|
||||
// Initialize with existing headers if available
|
||||
if (task.meta.req.extra != null && task.meta.req.extra is Map) {
|
||||
final extra = task.meta.req.extra as Map<String, dynamic>;
|
||||
if (extra.containsKey('header') && extra['header'] is Map) {
|
||||
final headers = extra['header'] as Map<String, dynamic>;
|
||||
for (final entry in headers.entries) {
|
||||
headerControllers.add(MapEntry(
|
||||
TextEditingController(text: entry.key),
|
||||
TextEditingController(text: entry.value.toString()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add one empty header row by default if none exists
|
||||
if (headerControllers.isEmpty) {
|
||||
headerControllers.add(MapEntry(
|
||||
TextEditingController(),
|
||||
TextEditingController(),
|
||||
));
|
||||
}
|
||||
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return AlertDialog(
|
||||
title: Text('updateUrl'.tr),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: urlController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'downloadLink'.tr,
|
||||
hintText: 'updateUrlDialogHint'.tr,
|
||||
icon: const Icon(Icons.link),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('httpHeader'.tr,
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
...headerControllers.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final controllers = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controllers.key,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'httpHeaderName'.tr,
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controllers.value,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'httpHeaderValue'.tr,
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
headerControllers.add(MapEntry(
|
||||
TextEditingController(),
|
||||
TextEditingController(),
|
||||
));
|
||||
});
|
||||
},
|
||||
iconSize: 20,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove),
|
||||
onPressed: () {
|
||||
if (headerControllers.length <= 1) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
headerControllers.removeAt(index);
|
||||
});
|
||||
},
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text('cancel'.tr),
|
||||
onPressed: () => Get.back(),
|
||||
),
|
||||
TextButton(
|
||||
child: Text('confirm'.tr),
|
||||
onPressed: () async {
|
||||
try {
|
||||
// Build headers map
|
||||
final headers = <String, String>{};
|
||||
for (final entry in headerControllers) {
|
||||
final key = entry.key.text.trim();
|
||||
final value = entry.value.text.trim();
|
||||
if (key.isNotEmpty) {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Build ReqExtraHttp
|
||||
final reqExtra = ReqExtraHttp(header: headers);
|
||||
|
||||
// Create patch request
|
||||
final patchData = ResolveTask(
|
||||
req: Request(
|
||||
url: urlController.text.trim(),
|
||||
extra: reqExtra.toJson(),
|
||||
),
|
||||
);
|
||||
|
||||
await patchTask(task.id, patchData);
|
||||
await continueTask(task.id);
|
||||
Get.back();
|
||||
} catch (e) {
|
||||
showErrorMessage(e);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> buildActions() {
|
||||
final list = <Widget>[];
|
||||
if (isDone()) {
|
||||
@@ -159,29 +317,6 @@ class BuildTaskListView extends GetView {
|
||||
return list;
|
||||
}
|
||||
|
||||
Widget buildContextItem(IconData icon, String label, Function() onTap,
|
||||
{bool enabled = true}) {
|
||||
return ListTile(
|
||||
dense: true,
|
||||
visualDensity: const VisualDensity(vertical: -1),
|
||||
minLeadingWidth: 12,
|
||||
leading: Icon(icon, size: 18),
|
||||
title: Text(label,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, // Make the text bold
|
||||
)),
|
||||
onTap: () async {
|
||||
Get.back();
|
||||
try {
|
||||
await onTap();
|
||||
} catch (e) {
|
||||
showErrorMessage(e);
|
||||
}
|
||||
},
|
||||
enabled: enabled,
|
||||
);
|
||||
}
|
||||
|
||||
double getProgress() {
|
||||
final totalSize = task.meta.res?.size ?? 0;
|
||||
return totalSize <= 0 ? 0 : task.progress.downloaded / totalSize;
|
||||
@@ -258,6 +393,7 @@ class BuildTaskListView extends GetView {
|
||||
}
|
||||
}
|
||||
|
||||
final appController = Get.find<AppController>();
|
||||
final taskController = Get.find<TaskController>();
|
||||
final taskListController = taskController.tabIndex.value == 0
|
||||
? Get.find<TaskDownloadingController>()
|
||||
@@ -268,19 +404,24 @@ class BuildTaskListView extends GetView {
|
||||
.where((id) => tasks.any((task) => task.id == id))
|
||||
.toList();
|
||||
|
||||
return ContextMenuArea(
|
||||
width: 140,
|
||||
builder: (context) => [
|
||||
buildContextItem(Icons.checklist, 'selectAll'.tr, () {
|
||||
// Build context menu entries
|
||||
final contextMenuEntries = <ContextMenuEntry>[
|
||||
MenuItem(
|
||||
icon: const Icon(Icons.checklist),
|
||||
label: Text('selectAll'.tr),
|
||||
onSelected: (_) {
|
||||
if (tasks.isEmpty) return;
|
||||
|
||||
if (selectedTaskIds.isNotEmpty) {
|
||||
taskListController.selectedTaskIds([]);
|
||||
} else {
|
||||
taskListController.selectedTaskIds(tasks.map((e) => e.id).toList());
|
||||
}
|
||||
}),
|
||||
buildContextItem(Icons.check, 'select'.tr, () {
|
||||
},
|
||||
),
|
||||
MenuItem(
|
||||
icon: const Icon(Icons.check),
|
||||
label: Text('select'.tr),
|
||||
onSelected: (_) {
|
||||
if (isSelect()) {
|
||||
taskListController.selectedTaskIds(taskListController
|
||||
.selectedTaskIds
|
||||
@@ -290,36 +431,89 @@ class BuildTaskListView extends GetView {
|
||||
taskListController.selectedTaskIds(
|
||||
[...taskListController.selectedTaskIds, task.id]);
|
||||
}
|
||||
}),
|
||||
const Divider(
|
||||
indent: 8,
|
||||
endIndent: 8,
|
||||
),
|
||||
buildContextItem(Icons.play_arrow, 'continue'.tr, () async {
|
||||
},
|
||||
),
|
||||
const MenuDivider(),
|
||||
MenuItem(
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: Text('continue'.tr),
|
||||
enabled: !isDone() && !isRunning(),
|
||||
onSelected: (_) async {
|
||||
try {
|
||||
await continueAllTasks(filterSelectedTaskIds(
|
||||
{...taskListController.selectedTaskIds, task.id}));
|
||||
} finally {
|
||||
taskListController.selectedTaskIds([]);
|
||||
}
|
||||
}, enabled: !isDone() && !isRunning()),
|
||||
buildContextItem(Icons.pause, 'pause'.tr, () async {
|
||||
},
|
||||
),
|
||||
MenuItem(
|
||||
icon: const Icon(Icons.pause),
|
||||
label: Text('pause'.tr),
|
||||
enabled: !isDone() && isRunning(),
|
||||
onSelected: (_) async {
|
||||
try {
|
||||
await pauseAllTasks(filterSelectedTaskIds(
|
||||
{...taskListController.selectedTaskIds, task.id}));
|
||||
} finally {
|
||||
taskListController.selectedTaskIds([]);
|
||||
}
|
||||
}, enabled: !isDone() && isRunning()),
|
||||
buildContextItem(Icons.delete, 'delete'.tr, () async {
|
||||
},
|
||||
),
|
||||
MenuItem(
|
||||
icon: const Icon(Icons.delete),
|
||||
label: Text('delete'.tr),
|
||||
onSelected: (_) async {
|
||||
try {
|
||||
await showDeleteDialog(filterSelectedTaskIds(
|
||||
{...taskListController.selectedTaskIds, task.id}));
|
||||
} finally {
|
||||
taskListController.selectedTaskIds([]);
|
||||
}
|
||||
}),
|
||||
],
|
||||
},
|
||||
),
|
||||
// Update URL submenu - only enabled for HTTP tasks in pause or error status
|
||||
const MenuDivider(),
|
||||
MenuItem.submenu(
|
||||
icon: const Icon(Icons.link),
|
||||
label: Text('updateUrl'.tr),
|
||||
enabled: task.protocol == Protocol.http &&
|
||||
(task.status == Status.pause || task.status == Status.error),
|
||||
items: [
|
||||
MenuItem(
|
||||
icon: const Icon(Icons.edit_note),
|
||||
label: Text('updateUrlManual'.tr),
|
||||
onSelected: (_) async {
|
||||
await showUpdateUrlDialog(context, task);
|
||||
},
|
||||
),
|
||||
MenuItem(
|
||||
icon: Icon(appController.pendingUpdateTask.value?.id == task.id
|
||||
? Icons.cancel
|
||||
: Icons.sensors),
|
||||
label: Text(appController.pendingUpdateTask.value?.id == task.id
|
||||
? 'updateUrlCancelListen'.tr
|
||||
: 'updateUrlListen'.tr),
|
||||
onSelected: (_) {
|
||||
if (appController.pendingUpdateTask.value?.id == task.id) {
|
||||
appController.pendingUpdateTask.value = null;
|
||||
} else {
|
||||
appController.pendingUpdateTask.value =
|
||||
PendingUpdateTask(id: task.id, name: task.name);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
final contextMenu = ContextMenu(
|
||||
entries: contextMenuEntries,
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
);
|
||||
|
||||
return ContextMenuRegion(
|
||||
contextMenu: contextMenu,
|
||||
child: Obx(
|
||||
() => Card(
|
||||
elevation: 4.0,
|
||||
@@ -344,7 +538,24 @@ class BuildTaskListView extends GetView {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(task.name),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(task.name)),
|
||||
// Show pending update indicator
|
||||
if (appController.pendingUpdateTask.value?.id ==
|
||||
task.id)
|
||||
Tooltip(
|
||||
message: 'updateUrlListeningTip'.tr,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Icon(Icons.hearing,
|
||||
size: 16,
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: Icon(
|
||||
fileIcon(task.name,
|
||||
isFolder: isFolderTask(),
|
||||
|
||||
@@ -147,6 +147,7 @@ const enUS = {
|
||||
'unknown': 'Unknown',
|
||||
'fileSelectedCount': 'Files: ',
|
||||
'fileSelectedSize': 'Size: ',
|
||||
'httpHeader': 'Header',
|
||||
'httpHeaderName': 'Header Name',
|
||||
'httpHeaderValue': 'Header Value',
|
||||
'login': 'Login',
|
||||
@@ -177,5 +178,16 @@ const enUS = {
|
||||
'githubMirrorType': 'Mirror Type',
|
||||
'githubMirrorUrl': 'Mirror URL',
|
||||
'githubMirrorUrlHint': 'Enter mirror URL',
|
||||
'updateUrl': 'Update URL',
|
||||
'updateUrlManual': 'Manual Update',
|
||||
'updateUrlListen': 'Listen Update',
|
||||
'updateUrlListeningTip': 'Waiting for URL update',
|
||||
'updateUrlCancelListen': 'Cancel Listen',
|
||||
'updateUrlDialogHint': 'Enter new download URL',
|
||||
'pendingUpdateFound': 'Pending Update Task Found',
|
||||
'pendingUpdateConfirm':
|
||||
'Task "@name" is waiting for URL update. Do you want to update it with the new URL?',
|
||||
'pendingUpdateYes': 'Update Task',
|
||||
'pendingUpdateNo': 'Create New Task',
|
||||
},
|
||||
};
|
||||
@@ -144,6 +144,7 @@ const zhCN = {
|
||||
'unknown': '未知',
|
||||
'fileSelectedCount': '文件数:',
|
||||
'fileSelectedSize': '大小:',
|
||||
'httpHeader': '请求头',
|
||||
'httpHeaderName': '请求头名称',
|
||||
'httpHeaderValue': '请求头值',
|
||||
'login': '登录',
|
||||
@@ -173,5 +174,15 @@ const zhCN = {
|
||||
'githubMirrorType': '镜像类型',
|
||||
'githubMirrorUrl': '镜像地址',
|
||||
'githubMirrorUrlHint': '请输入镜像地址',
|
||||
'updateUrl': '更新地址',
|
||||
'updateUrlManual': '手动更新',
|
||||
'updateUrlListen': '监听更新',
|
||||
'updateUrlListeningTip': '正在等待更新地址',
|
||||
'updateUrlCancelListen': '取消监听',
|
||||
'updateUrlDialogHint': '请输入新的下载地址',
|
||||
'pendingUpdateFound': '发现待更新任务',
|
||||
'pendingUpdateConfirm': '任务 "@name" 正在等待更新地址,是否使用新地址更新该任务?',
|
||||
'pendingUpdateYes': '更新任务',
|
||||
'pendingUpdateNo': '创建新任务',
|
||||
}
|
||||
};
|
||||
+8
-64
@@ -9,14 +9,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "67.0.0"
|
||||
after_layout:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: after_layout
|
||||
sha256: "95a1cb2ca1464f44f14769329fbf15987d20ab6c88f8fc5d359bd362be625f29"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -25,14 +17,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
animations:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: animations
|
||||
sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.11"
|
||||
app_links:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -233,22 +217,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.2"
|
||||
context_menus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: context_menus
|
||||
sha256: "3b4f846a7a7dcb8cc76bb0c8c1b3915501f5f7370c524e3aa00ab4cc048f08db"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
contextmenu_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: contextmenu_plus
|
||||
sha256: c4c3a11633ba98b6e325e80942cd6ca29269b246e307b0a43514ae4f9c8bcc7f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -393,19 +361,19 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
fluent_ui:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fluent_ui
|
||||
sha256: e7804bf3bbb3ecf9e77d5498181dc36375f5ca736ccfb3862fea17c50050eb89
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.2"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_context_menu:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_context_menu
|
||||
sha256: e8f2cd13ef5e3320e35b27b3f7de278d9fc6b3f9c328cc16cd90784fb66d1054
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.1"
|
||||
flutter_foreground_task:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -734,14 +702,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
math_expressions:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: math_expressions
|
||||
sha256: e32d803d758ace61cc6c4bdfed1226ff60a6a23646b35685670d28b5616139f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
menu_base:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1007,14 +967,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
recase:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: recase
|
||||
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
rounded_loading_button_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1039,14 +991,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.9"
|
||||
scroll_pos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: scroll_pos
|
||||
sha256: cebf602b2dd939de6832bb902ffefb574608d1b84f420b82b381a4007d3c1e1b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
share_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -37,7 +37,7 @@ dependencies:
|
||||
cupertino_icons: ^1.0.2
|
||||
get: ^4.6.6
|
||||
styled_widget: ^0.4.0+3
|
||||
context_menus: ^1.2.0
|
||||
flutter_context_menu: ^0.4.1
|
||||
json_annotation: ^4.8.1
|
||||
dio: ^5.2.1
|
||||
path_provider: ^2.1.4
|
||||
@@ -47,7 +47,6 @@ dependencies:
|
||||
logger: ^1.4.0
|
||||
desktop_drop: ^0.4.4
|
||||
package_info_plus: ^8.1.0
|
||||
fluent_ui: ^4.9.2
|
||||
path: ^1.9.0
|
||||
badges: ^3.0.3
|
||||
app_links: ^6.3.3
|
||||
@@ -72,7 +71,6 @@ dependencies:
|
||||
permission_handler: ^11.3.1
|
||||
device_info_plus: ^11.1.0
|
||||
checkable_treeview: ^1.3.1
|
||||
contextmenu_plus: ^1.0.1
|
||||
contentsize_tabbarview: ^0.0.2
|
||||
win32_registry: ^1.1.5
|
||||
share_handler: ^0.0.22
|
||||
|
||||
Reference in new issue
Block a user