mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-10 10:30:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6981d58e9 | ||
|
|
1b1631b41a | ||
|
|
3eb6df55df | ||
|
|
6de63c3f63 | ||
|
|
969615f353 | ||
|
|
5a88b98314 | ||
|
|
2674b1c39e | ||
|
|
4c90b38247 | ||
|
|
93088b22f1 | ||
|
|
fe90c1d261 |
No files matched your search
@@ -44,15 +44,17 @@ jobs:
|
||||
- arch: arm64
|
||||
os: windows-11-arm
|
||||
llvm_ver: "20251202" # Only ARM64
|
||||
flutter_channel: "master"
|
||||
flutter_version: "8817d45220d" # Only ARM64
|
||||
flutter_channel: "main"
|
||||
flutter_version: "7e1c8868"
|
||||
- arch: amd64
|
||||
os: windows-2022
|
||||
flutter_channel: "stable"
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Enable long paths for flutter main branch checks
|
||||
run: |
|
||||
git config --global core.longpaths true
|
||||
|
||||
# Install LLVM environment only on ARM64
|
||||
- name: Install llvm-mingw-ucrt-aarch64 (ARM64 only)
|
||||
@@ -84,7 +86,7 @@ jobs:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: ${{ matrix.flutter_channel }}
|
||||
channel: ${{ matrix.flutter_channel || 'stable' }}
|
||||
flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}
|
||||
- name: Build
|
||||
env:
|
||||
@@ -112,6 +114,7 @@ jobs:
|
||||
$release = "build\windows\arm64\runner\Release\"
|
||||
$mingw = "$env:CLANGARM64_ROOT\aarch64-w64-mingw32\bin"
|
||||
cp $mingw\libc++.dll $release
|
||||
cp $mingw\libunwind.dll $release
|
||||
}
|
||||
cp $mingw\libwinpthread-1.dll $release
|
||||
cp $system\msvcp140.dll $release
|
||||
|
||||
@@ -66,21 +66,56 @@ jobs:
|
||||
files: ./coverage.txt
|
||||
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
||||
build-desktop:
|
||||
name: Build desktop check
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-2022, macos-latest, ubuntu-22.04, ubuntu-22.04-arm]
|
||||
include:
|
||||
- os: windows-2022
|
||||
- os: windows-11-arm
|
||||
llvm_ver: "20251202" # Only ARM64
|
||||
flutter_channel: "main"
|
||||
flutter_version: "7e1c8868"
|
||||
- os: macos-latest
|
||||
- os: ubuntu-22.04
|
||||
- os: ubuntu-22.04-arm
|
||||
flutter_channel: "main"
|
||||
name: Build desktop check (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: [test]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Enable long paths for flutter main branch checks
|
||||
run: |
|
||||
git config --global core.longpaths true
|
||||
- name: Install llvm-mingw-ucrt-aarch64 (ARM64 only)
|
||||
if: matrix.os == 'windows-11-arm'
|
||||
run: |
|
||||
$ver = "${{ matrix.llvm_ver }}"
|
||||
$url = "https://github.com/mstorsjo/llvm-mingw/releases/download/$ver/llvm-mingw-$ver-ucrt-aarch64.zip"
|
||||
$zip = "$env:RUNNER_TEMP\\llvm.zip"
|
||||
$extract = "$env:RUNNER_TEMP\\extract"
|
||||
$target = "C:\\clangarm64"
|
||||
|
||||
curl -L $url -o $zip
|
||||
rm -r -fo $extract,$target -ea Ignore
|
||||
mkdir $extract | Out-Null
|
||||
|
||||
tar -xf $zip -C $extract
|
||||
mv (Get-ChildItem $extract)[0].FullName $target
|
||||
|
||||
$b = "$target\\bin"
|
||||
"CC=$b\\clang.exe" >> $env:GITHUB_ENV
|
||||
"CXX=$b\\clang++.exe" >> $env:GITHUB_ENV
|
||||
"CLANGARM64_BIN=$b" >> $env:GITHUB_ENV
|
||||
"CGO_ENABLED=1" >> $env:GITHUB_ENV
|
||||
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
||||
$b >> $env:GITHUB_PATH
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: master
|
||||
channel: ${{ matrix.flutter_channel || 'stable' }}
|
||||
flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}
|
||||
- if: runner.os == 'Windows'
|
||||
run: |
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.24.11 AS go
|
||||
FROM golang:1.24.11-alpine3.23 AS go
|
||||
WORKDIR /app
|
||||
COPY ./go.mod ./go.sum ./
|
||||
RUN go mod download
|
||||
@@ -8,7 +8,7 @@ RUN CGO_ENABLED=0 go build -tags nosqlite,web \
|
||||
-ldflags="-s -w -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION -X github.com/GopeedLab/gopeed/pkg/base.InDocker=true" \
|
||||
-o dist/gopeed github.com/GopeedLab/gopeed/cmd/web
|
||||
|
||||
FROM alpine:3.18
|
||||
FROM alpine:3.23
|
||||
LABEL maintainer="monkeyWie"
|
||||
WORKDIR /app
|
||||
COPY --from=go /app/dist/gopeed ./
|
||||
|
||||
@@ -164,13 +164,9 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
|
||||
github.com/dop251/goja v0.0.0-20240919115326-6c7d1df7ff05 h1:oK4+QcKsczZjHYTHD0JAdkvq5w74JEkG95J0XNBx/BI=
|
||||
github.com/dop251/goja v0.0.0-20240919115326-6c7d1df7ff05/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc h1:MKYt39yZJi0Z9xEeRmDX2L4ocE0ETKcHKw6MVL3R+co=
|
||||
@@ -286,8 +282,6 @@ github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OI
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
|
||||
github.com/google/pprof v0.0.0-20260106004452-d7df1bf2cac7 h1:kmPAX+IJBcUAFTddx2+xC0H7sk2U9ijIIxZLLrPLNng=
|
||||
github.com/google/pprof v0.0.0-20260106004452-d7df1bf2cac7/go.mod h1:67FPmZWbr+KDT/VlpWtw6sO9XSjpJmLuHpoLmWiTGgY=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
@@ -741,8 +735,6 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -588,6 +588,10 @@ func (f *Fetcher) Start() error {
|
||||
// Already downloading, this is a resume from pause
|
||||
return f.doStart()
|
||||
|
||||
case stateError:
|
||||
// Retry after error: reset and restart
|
||||
return f.doStart()
|
||||
|
||||
default:
|
||||
return fmt.Errorf("cannot start in current state: %v", state)
|
||||
}
|
||||
@@ -598,10 +602,31 @@ func (f *Fetcher) doStart() error {
|
||||
<-f.resolvedCh
|
||||
|
||||
state := f.getState()
|
||||
if state == stateDone || state == stateError {
|
||||
if state == stateDone {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If retrying after error, reset connection states for retry
|
||||
if state == stateError {
|
||||
// Drain any pending error from doneCh before retry
|
||||
select {
|
||||
case <-f.doneCh:
|
||||
default:
|
||||
}
|
||||
|
||||
f.connMu.Lock()
|
||||
for _, conn := range f.connections {
|
||||
// Reset connections that can be retried
|
||||
if !conn.Completed && conn.State != connCompleted {
|
||||
conn.State = connNotStarted
|
||||
conn.failed = false
|
||||
conn.retryTimes = 0
|
||||
conn.lastErr = nil
|
||||
}
|
||||
}
|
||||
f.connMu.Unlock()
|
||||
}
|
||||
|
||||
// Open or create target file first (needed for prefetch copy)
|
||||
name := f.meta.SingleFilepath()
|
||||
var err error
|
||||
@@ -948,6 +973,8 @@ func (f *Fetcher) runConnection(conn *connection) {
|
||||
}
|
||||
|
||||
// downloadChunkOnce performs a single HTTP request for the current chunk without retrying.
|
||||
// If the redirect URL fails with an expiration-related error (401, 403, 410),
|
||||
// it will automatically retry with the original URL and update the redirect URL on success.
|
||||
func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf []byte) error {
|
||||
if conn.ctx.Err() != nil {
|
||||
return conn.ctx.Err()
|
||||
@@ -981,11 +1008,34 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent {
|
||||
return NewRequestError(resp.StatusCode)
|
||||
resp.Body.Close()
|
||||
originalErr := NewRequestError(resp.StatusCode)
|
||||
|
||||
// Check if this might be a redirect URL expiration error
|
||||
// If so, try falling back to the original URL
|
||||
if f.hasRedirectURL() && isRedirectExpiredError(originalErr) {
|
||||
fallbackResp, fallbackErr := f.tryFallbackToOriginalURL(conn.ctx, client, rangeStart, rangeEnd)
|
||||
if fallbackErr == nil && fallbackResp != nil {
|
||||
// Fallback succeeded, use this response instead
|
||||
resp = fallbackResp
|
||||
// Update the redirect URL from the response
|
||||
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 {
|
||||
fallbackResp.Body.Close()
|
||||
}
|
||||
return originalErr
|
||||
}
|
||||
} else {
|
||||
return originalErr
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Record successful connection time for adaptive timeout
|
||||
f.updateMaxConnTime(time.Since(connStartTime))
|
||||
@@ -1523,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()
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
type fetcherData struct {
|
||||
Connections []*connection
|
||||
RedirectURL string // Saved redirect URL for resume
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -71,8 +72,12 @@ func (fm *FetcherManager) DefaultConfig() any {
|
||||
|
||||
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
||||
_f := f.(*Fetcher)
|
||||
_f.redirectLock.Lock()
|
||||
redirectURL := _f.redirectURL
|
||||
_f.redirectLock.Unlock()
|
||||
return &fetcherData{
|
||||
Connections: _f.connections,
|
||||
RedirectURL: redirectURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -87,6 +92,10 @@ func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v
|
||||
if len(fd.Connections) > 0 {
|
||||
fetcher.connections = fd.Connections
|
||||
}
|
||||
// Restore redirect URL for resume
|
||||
if fd.RedirectURL != "" {
|
||||
fetcher.redirectURL = fd.RedirectURL
|
||||
}
|
||||
return fetcher
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,6 +778,168 @@ func TestFetcher_AsyncPrefetch(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestFetcher_DownloadExpiringRedirectURL tests that the fetcher correctly handles
|
||||
// expiring redirect URLs by falling back to the original URL and getting a new redirect.
|
||||
func TestFetcher_DownloadExpiringRedirectURL(t *testing.T) {
|
||||
// Test with redirect expiring after 2 requests
|
||||
// This means:
|
||||
// - Request 1: Resolve (original URL redirects to temp URL v1)
|
||||
// - Request 2: First download request to temp URL v1 succeeds
|
||||
// - Request 3: Second download request to temp URL v1 returns 403
|
||||
// - Fetcher should then retry with original URL, get temp URL v2
|
||||
// - Continue downloading from temp URL v2
|
||||
t.Run("RedirectExpiresAfter2Requests", func(t *testing.T) {
|
||||
os.Remove(test.DownloadFile)
|
||||
|
||||
// Create server with redirect expiring after 2 requests, with slow transfer to ensure
|
||||
// multiple connection attempts are needed
|
||||
listener := test.StartTestExpiringRedirectServer(2, 100*time.Nanosecond)
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4, // Use multiple connections to trigger redirect expiration
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = fetcher.Start()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = fetcher.Wait()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify file content is correct despite redirect expiration
|
||||
want := test.FileMd5(test.BuildFile)
|
||||
got := test.FileMd5(test.DownloadFile)
|
||||
if want != got {
|
||||
t.Errorf("Download() got = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
os.Remove(test.DownloadFile)
|
||||
})
|
||||
|
||||
// Test with redirect expiring after 5 requests (more room for initial connections)
|
||||
t.Run("RedirectExpiresAfter5Requests", func(t *testing.T) {
|
||||
os.Remove(test.DownloadFile)
|
||||
|
||||
listener := test.StartTestExpiringRedirectServer(5, 100*time.Nanosecond)
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 8,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = fetcher.Start()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = fetcher.Wait()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify file content
|
||||
want := test.FileMd5(test.BuildFile)
|
||||
got := test.FileMd5(test.DownloadFile)
|
||||
if want != got {
|
||||
t.Errorf("Download() got = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
os.Remove(test.DownloadFile)
|
||||
})
|
||||
}
|
||||
|
||||
// TestFetcher_RetryAfterError tests that the fetcher can retry downloading
|
||||
// after a previous download attempt failed by calling Start() again.
|
||||
func TestFetcher_RetryAfterError(t *testing.T) {
|
||||
os.Remove(test.DownloadFile)
|
||||
|
||||
// Server fails first 3 requests (after resolve), then recovers
|
||||
// With 1 connection and 3 retries:
|
||||
// - First Start(): requests 2, 3, 4 → all fail (3 retries exhausted) → returns error
|
||||
// - Second Start(): request 5 → succeeds (server recovered after 3 failures)
|
||||
listener := test.StartTestFailThenRecoverServer(3)
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1, // Use single connection to simplify test
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// First download attempt - should fail because server returns 416 after resolve
|
||||
err = fetcher.Start()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = fetcher.Wait()
|
||||
// First attempt should fail with 416 error
|
||||
if err == nil {
|
||||
t.Fatal("Expected first download attempt to fail, but it succeeded")
|
||||
}
|
||||
t.Logf("First attempt failed as expected: %v", err)
|
||||
|
||||
// Check that fetcher is in error state
|
||||
state := fetcher.getState()
|
||||
if state != stateError {
|
||||
t.Errorf("Expected fetcher to be in stateError, got %v", state)
|
||||
}
|
||||
|
||||
// Verify that we can call Start() again after error
|
||||
// This tests the stateError handling in Start()
|
||||
err = fetcher.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("Start() after error failed: %v", err)
|
||||
}
|
||||
|
||||
// Wait for second attempt - should succeed now that server has recovered
|
||||
err = fetcher.Wait()
|
||||
if err != nil {
|
||||
t.Fatalf("Retry failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify file content
|
||||
want := test.FileMd5(test.BuildFile)
|
||||
got := test.FileMd5(test.DownloadFile)
|
||||
if want != got {
|
||||
t.Errorf("Download() got = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
os.Remove(test.DownloadFile)
|
||||
}
|
||||
|
||||
func TestFetcherManager_ParseName(t *testing.T) {
|
||||
type args struct {
|
||||
u string
|
||||
@@ -1046,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)
|
||||
}
|
||||
}
|
||||
@@ -65,14 +65,23 @@ func extractRequestError(err error) *RequestError {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HTTP Request/Client Building
|
||||
// ============================================================================
|
||||
|
||||
// buildRequest creates an HTTP request using the redirect URL if available.
|
||||
func (f *Fetcher) buildRequest(ctx context.Context, req *base.Request) (httpReq *http.Request, err error) {
|
||||
return f.buildRequestWithURL(ctx, req, true)
|
||||
}
|
||||
|
||||
// buildRequestWithOriginalURL creates an HTTP request using the original URL.
|
||||
// This is used for retrying when the redirect URL has expired.
|
||||
func (f *Fetcher) buildRequestWithOriginalURL(ctx context.Context, req *base.Request) (httpReq *http.Request, err error) {
|
||||
return f.buildRequestWithURL(ctx, req, false)
|
||||
}
|
||||
|
||||
// buildRequestWithURL creates an HTTP request.
|
||||
// If useRedirect is true and a redirect URL exists, it will be used; otherwise the original URL is used.
|
||||
func (f *Fetcher) buildRequestWithURL(ctx context.Context, req *base.Request, useRedirect bool) (httpReq *http.Request, err error) {
|
||||
var reqUrl string
|
||||
f.redirectLock.Lock()
|
||||
if f.redirectURL != "" {
|
||||
if useRedirect && f.redirectURL != "" {
|
||||
reqUrl = f.redirectURL
|
||||
} else {
|
||||
reqUrl = req.URL
|
||||
@@ -121,6 +130,65 @@ func (f *Fetcher) buildRequest(ctx context.Context, req *base.Request) (httpReq
|
||||
return httpReq, nil
|
||||
}
|
||||
|
||||
// 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(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.
|
||||
func (f *Fetcher) hasRedirectURL() bool {
|
||||
f.redirectLock.Lock()
|
||||
defer f.redirectLock.Unlock()
|
||||
return f.redirectURL != "" && f.redirectURL != f.meta.Req.URL
|
||||
}
|
||||
|
||||
// isRedirectExpiredError checks if the error indicates that the redirect URL may have expired.
|
||||
// This includes 403 (Forbidden), 401 (Unauthorized), 410 (Gone), and network errors.
|
||||
func isRedirectExpiredError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for specific HTTP error codes that might indicate URL expiration
|
||||
if re := extractRequestError(err); re != nil {
|
||||
switch re.Code {
|
||||
case 401, 403, 404, 410:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// tryFallbackToOriginalURL attempts to make a request using the original URL
|
||||
// when the redirect URL has expired. Returns the response if successful.
|
||||
func (f *Fetcher) tryFallbackToOriginalURL(ctx context.Context, client *http.Client, rangeStart, rangeEnd int64) (*http.Response, error) {
|
||||
httpReq, err := f.buildRequestWithOriginalURL(ctx, f.meta.Req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if f.meta.Res.Range && rangeEnd > 0 {
|
||||
httpReq.Header.Set(base.HttpHeaderRange,
|
||||
fmt.Sprintf(base.HttpHeaderRangeFormat, rangeStart, rangeEnd))
|
||||
}
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent {
|
||||
resp.Body.Close()
|
||||
return nil, NewRequestError(resp.StatusCode)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// buildClient creates an HTTP client with the default connection timeout.
|
||||
// Used for resolve phase where we don't have connection time data yet.
|
||||
func (f *Fetcher) buildClient() *http.Client {
|
||||
|
||||
@@ -335,6 +335,67 @@ func StartTestOneTimeServer() net.Listener {
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestExpiringRedirectServer creates a server that simulates expiring redirect URLs.
|
||||
// The original URL redirects to a temporary URL that expires after a specified number of requests.
|
||||
// When the temporary URL expires (returns 403), the client should retry with the original URL
|
||||
// to get a new redirect URL.
|
||||
// Parameters:
|
||||
// - requestsBeforeExpire: number of requests the temporary URL accepts before expiring
|
||||
// - delayPerByte: optional delay per byte for slow transfer (use 0 for no delay)
|
||||
func StartTestExpiringRedirectServer(requestsBeforeExpire int32, delayPerByte time.Duration) net.Listener {
|
||||
var redirectVersion atomic.Int32
|
||||
var requestCount atomic.Int32
|
||||
redirectVersion.Store(1)
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
path := request.URL.Path
|
||||
|
||||
// Original URL - redirects to current version of temporary URL
|
||||
if path == "/"+BuildName {
|
||||
// Reset request count when original URL is accessed
|
||||
requestCount.Store(0)
|
||||
// Redirect to versioned temporary URL
|
||||
version := redirectVersion.Load()
|
||||
redirectURL := fmt.Sprintf("/redirect-v%d/%s", version, BuildName)
|
||||
http.Redirect(writer, request, redirectURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Temporary URL handler - matches /redirect-v{N}/... pattern
|
||||
if strings.HasPrefix(path, "/redirect-v") {
|
||||
// Check if the redirect has expired
|
||||
count := requestCount.Add(1)
|
||||
if count > requestsBeforeExpire {
|
||||
// Redirect expired - increment version for next redirect
|
||||
redirectVersion.Add(1)
|
||||
writer.WriteHeader(403)
|
||||
writer.Write([]byte("Redirect URL expired"))
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the file with range support
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
if delayPerByte > 0 {
|
||||
slowCopyNWithDelay(sl, writer, file, n, delayPerByte)
|
||||
} else {
|
||||
io.CopyN(writer, file, n)
|
||||
}
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Not found
|
||||
writer.WriteHeader(404)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestSlowStartServer creates a server with configurable delay per request
|
||||
// This allows testing slow-start connection expansion to reach max connections
|
||||
func StartTestSlowStartServer(delayPerByte time.Duration) net.Listener {
|
||||
@@ -496,6 +557,57 @@ func StartTestTemporary500Server(errorDuration time.Duration) net.Listener {
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestFailThenRecoverServer creates a server that fails all connections initially,
|
||||
// then recovers after a specified number of failed requests.
|
||||
// This tests the retry functionality when calling Start() again after a download fails.
|
||||
// Parameters:
|
||||
// - failedRequestsBeforeRecover: number of requests that will fail before server recovers
|
||||
//
|
||||
// Note: Uses 416 (Range Not Satisfiable) instead of 500 because 5xx errors are exempt
|
||||
// from failure counting and will retry indefinitely. 416 is counted as a failure.
|
||||
func StartTestFailThenRecoverServer(failedRequestsBeforeRecover int32) 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)
|
||||
|
||||
// First request (Resolve) always succeeds
|
||||
if reqNum == 1 {
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
io.CopyN(writer, file, n)
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Subsequent requests fail until we've had enough failures
|
||||
// Use 416 Range Not Satisfiable - this error is counted towards failure limit
|
||||
if reqNum <= failedRequestsBeforeRecover+1 { // +1 because first request is resolve
|
||||
writer.WriteHeader(416)
|
||||
writer.Write([]byte("Range Not Satisfiable"))
|
||||
return
|
||||
}
|
||||
|
||||
// After enough failures, server recovers
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
io.CopyN(writer, file, n)
|
||||
},
|
||||
)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestRangeBugServer simulate bug server:
|
||||
// Don't follow Range request rules, always return more data than range, e.g. Range: bytes=0-100, return 150 bytes
|
||||
func StartTestRangeBugServer() net.Listener {
|
||||
@@ -739,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 {
|
||||
|
||||
+15
-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
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,7 @@ type DownloaderStoreConfig struct {
|
||||
Extra map[string]any `json:"extra"`
|
||||
Proxy *DownloaderProxyConfig `json:"proxy"`
|
||||
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
|
||||
Script *ScriptConfig `json:"script"` // Script is the script execution configuration
|
||||
AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration
|
||||
Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration
|
||||
AutoDeleteMissingFileTasks bool `json:"autoDeleteMissingFileTasks"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files
|
||||
@@ -202,6 +203,9 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
|
||||
if cfg.Webhook == nil {
|
||||
cfg.Webhook = &WebhookConfig{}
|
||||
}
|
||||
if cfg.Script == nil {
|
||||
cfg.Script = &ScriptConfig{}
|
||||
}
|
||||
if cfg.AutoTorrent == nil {
|
||||
cfg.AutoTorrent = &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
@@ -239,6 +243,9 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
|
||||
if cfg.Webhook == nil {
|
||||
cfg.Webhook = beforeCfg.Webhook
|
||||
}
|
||||
if cfg.Script == nil {
|
||||
cfg.Script = beforeCfg.Script
|
||||
}
|
||||
if cfg.AutoTorrent == nil {
|
||||
cfg.AutoTorrent = beforeCfg.AutoTorrent
|
||||
}
|
||||
@@ -254,6 +261,12 @@ type WebhookConfig struct {
|
||||
URLs []string `json:"urls"` // URLs is the list of webhook URLs
|
||||
}
|
||||
|
||||
// ScriptConfig is the script execution configuration
|
||||
type ScriptConfig struct {
|
||||
Enable bool `json:"enable"` // Enable is the flag to enable/disable script execution
|
||||
Paths []string `json:"paths"` // Paths is the list of script paths to execute
|
||||
}
|
||||
|
||||
// AutoTorrentConfig is the auto torrent task creation configuration
|
||||
type AutoTorrentConfig struct {
|
||||
Enable bool `json:"enable"` // Enable enables automatic BT task creation when downloading .torrent files
|
||||
|
||||
@@ -19,6 +19,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
@@ -39,6 +40,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
@@ -63,6 +65,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
@@ -87,6 +90,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
Enable: true,
|
||||
},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
@@ -110,6 +114,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: true,
|
||||
DeleteAfterDownload: true,
|
||||
@@ -133,6 +138,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
@@ -154,6 +160,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
Extra: tt.fields.Extra,
|
||||
Proxy: tt.fields.Proxy,
|
||||
Webhook: tt.fields.Webhook,
|
||||
Script: tt.fields.Script,
|
||||
AutoTorrent: tt.fields.AutoTorrent,
|
||||
Archive: tt.fields.Archive,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -954,6 +995,7 @@ func (d *Downloader) watch(task *Task) {
|
||||
d.notifyRunning()
|
||||
d.triggerOnDone(task)
|
||||
d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||
d.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
|
||||
if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {
|
||||
downloadFilePath := task.Meta.SingleFilepath()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -12,6 +12,7 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/mholt/archives"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
// supportedArchiveExtensions contains file extensions supported by mholt/archives library
|
||||
@@ -70,6 +71,16 @@ type ArchivePartInfo struct {
|
||||
// ExtractProgressCallback is called to report extraction progress
|
||||
type ExtractProgressCallback func(extractedFiles int, totalFiles int, progress int)
|
||||
|
||||
// newZipFormat creates a Zip format with proper character encoding support.
|
||||
// It uses GB18030 encoding to handle Chinese characters in filenames that may
|
||||
// be encoded with legacy GBK/GB18030 instead of UTF-8.
|
||||
func newZipFormat() archives.Zip {
|
||||
return archives.Zip{
|
||||
// GB18030 is a superset of GBK and handles Chinese characters correctly
|
||||
TextEncoding: simplifiedchinese.GB18030,
|
||||
}
|
||||
}
|
||||
|
||||
// isArchiveFile checks if a file is a supported archive format
|
||||
func isArchiveFile(filename string) bool {
|
||||
lowerName := strings.ToLower(filename)
|
||||
@@ -109,7 +120,8 @@ func openArchive(archivePath string, password string) (*archiveInfo, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Handle password-protected archives
|
||||
// Configure format-specific settings
|
||||
// Handle password-protected archives and character encoding
|
||||
if password != "" {
|
||||
if rar, ok := format.(archives.Rar); ok {
|
||||
rar.Password = password
|
||||
@@ -121,6 +133,12 @@ func openArchive(archivePath string, password string) (*archiveInfo, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// For ZIP files, configure character encoding to handle non-UTF8 filenames
|
||||
// This is essential for Chinese characters encoded in GBK/GB18030
|
||||
if _, ok := format.(archives.Zip); ok {
|
||||
format = newZipFormat()
|
||||
}
|
||||
|
||||
return &archiveInfo{
|
||||
file: file,
|
||||
stat: stat,
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
func TestIsArchiveFile(t *testing.T) {
|
||||
@@ -243,6 +245,69 @@ func createTestZipWithMultipleFiles(path string, numFiles int) error {
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
// createTestZipWithChineseFilenames creates a test ZIP file with Chinese filenames encoded in GBK
|
||||
func createTestZipWithChineseFilenames(path string) error {
|
||||
zipFile, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zipFile.Close()
|
||||
|
||||
w := zip.NewWriter(zipFile)
|
||||
|
||||
// Encode Chinese filenames in GBK (as some legacy Windows applications do)
|
||||
encoder := simplifiedchinese.GBK.NewEncoder()
|
||||
|
||||
// Add a file with Chinese filename
|
||||
chineseFilename := "测试文件.txt"
|
||||
gbkFilename, err := encoder.String(chineseFilename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a FileHeader and manually set the Name with GBK encoding
|
||||
// We need to mark it as non-UTF8 by not setting the UTF-8 flag
|
||||
header := &zip.FileHeader{
|
||||
Name: gbkFilename,
|
||||
Method: zip.Deflate,
|
||||
}
|
||||
// Clear the UTF-8 bit (bit 11) to indicate non-UTF8 encoding
|
||||
header.Flags = 0
|
||||
|
||||
f, err := w.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.Write([]byte("这是测试内容"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add a file in a subdirectory with Chinese name
|
||||
chineseDirAndFile := "文件夹/中文内容.txt"
|
||||
gbkDirAndFile, err := encoder.String(chineseDirAndFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
header2 := &zip.FileHeader{
|
||||
Name: gbkDirAndFile,
|
||||
Method: zip.Deflate,
|
||||
}
|
||||
header2.Flags = 0
|
||||
|
||||
f2, err := w.CreateHeader(header2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f2.Write([]byte("中文子文件内容"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
func TestOpenArchive_NonExistentFile(t *testing.T) {
|
||||
_, err := openArchive("/nonexistent/path/file.zip", "")
|
||||
if err == nil {
|
||||
@@ -2382,3 +2447,47 @@ func TestExtractZipMultiPart_Progress(t *testing.T) {
|
||||
t.Error("Expected progress callbacks")
|
||||
}
|
||||
}
|
||||
|
||||
// Test extracting ZIP files with Chinese filenames encoded in GBK/GB18030
|
||||
func TestExtractArchive_ChineseFilenames(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "extract_chinese_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create a test ZIP file with Chinese filenames encoded in GBK
|
||||
zipPath := filepath.Join(tempDir, "chinese.zip")
|
||||
destDir := filepath.Join(tempDir, "extracted")
|
||||
|
||||
if err := createTestZipWithChineseFilenames(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Extract the archive
|
||||
err = extractArchive(zipPath, destDir, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("extractArchive failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the extracted files with proper Chinese filenames
|
||||
expectedFiles := []string{
|
||||
filepath.Join(destDir, "测试文件.txt"),
|
||||
filepath.Join(destDir, "文件夹", "中文内容.txt"),
|
||||
}
|
||||
|
||||
for _, path := range expectedFiles {
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
t.Errorf("expected file %q not found after extraction", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify content of the Chinese file
|
||||
content, err := os.ReadFile(filepath.Join(destDir, "测试文件.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "这是测试内容" {
|
||||
t.Errorf("unexpected content: %q", string(content))
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func extractZipMultiPart(firstPartPath string, destDir string, password string,
|
||||
|
||||
// First pass: count files for progress
|
||||
totalFiles := 0
|
||||
zip := archives.Zip{}
|
||||
zip := newZipFormat()
|
||||
err = zip.Extract(context.Background(), io.NewSectionReader(multiReader, 0, totalSize), func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||
if !fileInfo.IsDir() {
|
||||
totalFiles++
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ScriptEvent represents the type of script event
|
||||
type ScriptEvent string
|
||||
|
||||
const (
|
||||
ScriptEventDownloadDone ScriptEvent = "DOWNLOAD_DONE"
|
||||
ScriptEventDownloadError ScriptEvent = "DOWNLOAD_ERROR"
|
||||
)
|
||||
|
||||
// ScriptData is the internal data structure for passing script information
|
||||
type ScriptData struct {
|
||||
Event ScriptEvent
|
||||
Time int64 // Unix timestamp in milliseconds
|
||||
Payload *ScriptPayload
|
||||
}
|
||||
|
||||
// ScriptPayload contains the task data
|
||||
type ScriptPayload struct {
|
||||
Task *Task
|
||||
}
|
||||
|
||||
// getScriptPaths extracts script paths from config
|
||||
func (d *Downloader) getScriptPaths() []string {
|
||||
cfg := d.cfg.DownloaderStoreConfig
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check new script config
|
||||
if cfg.Script != nil && cfg.Script.Enable && len(cfg.Script.Paths) > 0 {
|
||||
paths := make([]string, 0, len(cfg.Script.Paths))
|
||||
for _, path := range cfg.Script.Paths {
|
||||
if path != "" {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
if len(paths) > 0 {
|
||||
return paths
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeScriptAtPath executes a single script with the given data
|
||||
// Returns any error that occurred during execution
|
||||
func (d *Downloader) executeScriptAtPath(scriptPath string, data *ScriptData) error {
|
||||
if scriptPath == "" {
|
||||
return fmt.Errorf("script path is empty")
|
||||
}
|
||||
|
||||
// Check if script file exists
|
||||
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("script file does not exist: %s", scriptPath)
|
||||
}
|
||||
|
||||
// Determine the script interpreter based on file extension
|
||||
var cmd *exec.Cmd
|
||||
ext := filepath.Ext(scriptPath)
|
||||
|
||||
switch ext {
|
||||
case ".sh", ".bash":
|
||||
cmd = exec.Command("bash", scriptPath)
|
||||
case ".py":
|
||||
cmd = exec.Command("python3", scriptPath)
|
||||
case ".js":
|
||||
cmd = exec.Command("node", scriptPath)
|
||||
case ".bat", ".cmd":
|
||||
// Windows batch files
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("cmd", "/c", scriptPath)
|
||||
} else {
|
||||
// Batch files are Windows-specific
|
||||
return fmt.Errorf("batch files (.bat/.cmd) are only supported on Windows")
|
||||
}
|
||||
case ".ps1":
|
||||
// PowerShell scripts
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", scriptPath)
|
||||
} else {
|
||||
// Try pwsh (PowerShell Core) on non-Windows systems
|
||||
cmd = exec.Command("pwsh", "-File", scriptPath)
|
||||
}
|
||||
case "":
|
||||
// No extension, try to execute directly (assumes shebang or executable)
|
||||
cmd = exec.Command(scriptPath)
|
||||
default:
|
||||
// Unknown extension, try to execute directly
|
||||
cmd = exec.Command(scriptPath)
|
||||
}
|
||||
|
||||
// Set environment variables with task information
|
||||
cmd.Env = append(os.Environ(),
|
||||
fmt.Sprintf("GOPEED_EVENT=%s", data.Event),
|
||||
fmt.Sprintf("GOPEED_TASK_ID=%s", data.Payload.Task.ID),
|
||||
fmt.Sprintf("GOPEED_TASK_NAME=%s", data.Payload.Task.Name()),
|
||||
fmt.Sprintf("GOPEED_TASK_STATUS=%s", data.Payload.Task.Status),
|
||||
)
|
||||
|
||||
// Add task path using the same logic as task deletion
|
||||
if data.Payload.Task.Meta != nil && data.Payload.Task.Meta.Res != nil {
|
||||
var taskPath string
|
||||
if data.Payload.Task.Meta.Res.Name != "" {
|
||||
// Multi-file task (folder)
|
||||
taskPath = data.Payload.Task.Meta.FolderPath()
|
||||
} else {
|
||||
// Single file task
|
||||
taskPath = data.Payload.Task.Meta.SingleFilepath()
|
||||
}
|
||||
cmd.Env = append(cmd.Env,
|
||||
fmt.Sprintf("GOPEED_TASK_PATH=%s", taskPath),
|
||||
)
|
||||
}
|
||||
|
||||
// Start and wait for the command to complete (no timeout)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// triggerScripts executes all configured scripts
|
||||
func (d *Downloader) triggerScripts(event ScriptEvent, task *Task, err error) {
|
||||
paths := d.getScriptPaths()
|
||||
if len(paths) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
data := &ScriptData{
|
||||
Event: event,
|
||||
Time: time.Now().UnixMilli(),
|
||||
Payload: &ScriptPayload{
|
||||
Task: task.clone(),
|
||||
},
|
||||
}
|
||||
|
||||
go d.executeScripts(paths, data)
|
||||
}
|
||||
|
||||
func (d *Downloader) executeScripts(paths []string, data *ScriptData) {
|
||||
for _, path := range paths {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
go func(scriptPath string) {
|
||||
err := d.executeScriptAtPath(scriptPath, data)
|
||||
if err != nil {
|
||||
d.Logger.Warn().Err(err).Str("path", scriptPath).Msg("script: failed to execute")
|
||||
return
|
||||
}
|
||||
d.Logger.Debug().Str("path", scriptPath).Msg("script: executed successfully")
|
||||
}(path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
func TestScript_NoScriptConfigured(t *testing.T) {
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
// Create a mock task
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Meta = &mockFetcherMeta
|
||||
|
||||
// Trigger script (should not panic with no scripts configured)
|
||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_GetScriptPaths_EmptyConfig(t *testing.T) {
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
paths := downloader.getScriptPaths()
|
||||
if paths != nil {
|
||||
t.Errorf("Expected nil, got %v", paths)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_GetScriptPaths_NoScriptConfig(t *testing.T) {
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = nil
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
paths := downloader.getScriptPaths()
|
||||
if paths != nil {
|
||||
t.Errorf("Expected nil, got %v", paths)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_GetScriptPaths_DisabledScript(t *testing.T) {
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: false,
|
||||
Paths: []string{"/path/to/script.sh"},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
paths := downloader.getScriptPaths()
|
||||
if paths != nil {
|
||||
t.Errorf("Expected nil for disabled script, got %v", paths)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_GetScriptPaths_EmptyPaths(t *testing.T) {
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: true,
|
||||
Paths: []string{},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
paths := downloader.getScriptPaths()
|
||||
if paths != nil {
|
||||
t.Errorf("Expected nil for empty paths, got %v", paths)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_GetScriptPaths_WithEmptyStrings(t *testing.T) {
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: true,
|
||||
Paths: []string{"/path/to/script1.sh", "", "/path/to/script2.sh", ""},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
paths := downloader.getScriptPaths()
|
||||
if len(paths) != 2 {
|
||||
t.Errorf("Expected 2 valid paths (ignoring empty strings), got %d: %v", len(paths), paths)
|
||||
}
|
||||
if paths[0] != "/path/to/script1.sh" || paths[1] != "/path/to/script2.sh" {
|
||||
t.Errorf("Paths don't match expected values: %v", paths)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_ExecuteScriptAtPath_EmptyPath(t *testing.T) {
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
data := &ScriptData{
|
||||
Event: ScriptEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
err := downloader.executeScriptAtPath("", data)
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty path")
|
||||
}
|
||||
if err.Error() != "script path is empty" {
|
||||
t.Errorf("Expected 'script path is empty' error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_ExecuteScriptAtPath_NonExistentFile(t *testing.T) {
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
data := &ScriptData{
|
||||
Event: ScriptEventDownloadDone,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
err := downloader.executeScriptAtPath("/non/existent/script.sh", data)
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent script")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func createDownloadDoneTask(t *testing.T, downloadDir, fileName string) (*Task, string) {
|
||||
t.Helper()
|
||||
content := []byte("downloaded file")
|
||||
task := NewTask()
|
||||
task.Protocol = "http"
|
||||
task.Status = base.DownloadStatusDone
|
||||
task.Meta = &fetcher.FetcherMeta{
|
||||
Req: &base.Request{
|
||||
URL: "https://example.com/" + fileName,
|
||||
},
|
||||
Opts: &base.Options{
|
||||
Name: fileName,
|
||||
Path: filepath.ToSlash(downloadDir),
|
||||
},
|
||||
Res: &base.Resource{
|
||||
Size: int64(len(content)),
|
||||
Files: []*base.FileInfo{
|
||||
{Name: fileName, Size: int64(len(content))},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
filePath := task.Meta.SingleFilepath()
|
||||
filePathOS := filepath.FromSlash(filePath)
|
||||
if err := os.MkdirAll(filepath.Dir(filePathOS), 0755); err != nil {
|
||||
t.Fatalf("Failed to create download dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filePathOS, content, 0644); err != nil {
|
||||
t.Fatalf("Failed to create download file: %v", err)
|
||||
}
|
||||
return task, filePath
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("Timeout waiting for file: %s", path)
|
||||
}
|
||||
|
||||
func getTestScriptPath(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join("testdata", "scripts", name)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("Missing test script %s: %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func ensureScriptExecutable(t *testing.T, scriptPath string) {
|
||||
t.Helper()
|
||||
if filepath.Ext(scriptPath) != ".sh" {
|
||||
return
|
||||
}
|
||||
if err := os.Chmod(scriptPath, 0755); err != nil {
|
||||
t.Fatalf("Failed to chmod script: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupScriptTest(t *testing.T, fn func(downloader *Downloader)) {
|
||||
defaultDownloader.Setup()
|
||||
defaultDownloader.cfg.StorageDir = ".test_storage"
|
||||
defaultDownloader.cfg.DownloadDir = ".test_download"
|
||||
defer func() {
|
||||
defaultDownloader.Clear()
|
||||
os.RemoveAll(defaultDownloader.cfg.StorageDir)
|
||||
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
|
||||
}()
|
||||
fn(defaultDownloader)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package download
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
func TestScript_TriggerOnDone_MoveFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
||||
destDir := filepath.Join(tmpDir, "moved")
|
||||
task, taskPath := createDownloadDoneTask(t, downloadDir, "test.txt")
|
||||
srcPath := filepath.FromSlash(taskPath)
|
||||
destFile := filepath.Join(destDir, "test.txt")
|
||||
|
||||
scriptPath := getTestScriptPath(t, "move.sh")
|
||||
ensureScriptExecutable(t, scriptPath)
|
||||
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: true,
|
||||
Paths: []string{scriptPath},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
t.Setenv("GOPEED_TEST_DEST_DIR", destDir)
|
||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
|
||||
waitForFile(t, destFile, 3*time.Second)
|
||||
if _, err := os.Stat(srcPath); !os.IsNotExist(err) {
|
||||
t.Errorf("Expected source file to be moved, but it still exists: %s", srcPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_MultipleScripts(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputFile1 := filepath.Join(tmpDir, "output1.txt")
|
||||
outputFile2 := filepath.Join(tmpDir, "output2.txt")
|
||||
scriptPath1 := getTestScriptPath(t, "write_output1.sh")
|
||||
ensureScriptExecutable(t, scriptPath1)
|
||||
scriptPath2 := getTestScriptPath(t, "write_output2.sh")
|
||||
ensureScriptExecutable(t, scriptPath2)
|
||||
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: true,
|
||||
Paths: []string{scriptPath1, scriptPath2},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
||||
task, _ := createDownloadDoneTask(t, downloadDir, "multi.txt")
|
||||
|
||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE_1", outputFile1)
|
||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE_2", outputFile2)
|
||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
|
||||
waitForFile(t, outputFile1, 3*time.Second)
|
||||
waitForFile(t, outputFile2, 3*time.Second)
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_EnvironmentVariables(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := getTestScriptPath(t, "env_dump.sh")
|
||||
ensureScriptExecutable(t, scriptPath)
|
||||
outputFile := filepath.Join(tmpDir, "env_output.txt")
|
||||
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: true,
|
||||
Paths: []string{scriptPath},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
||||
task, taskPath := createDownloadDoneTask(t, downloadDir, "env.txt")
|
||||
|
||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile)
|
||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
|
||||
waitForFile(t, outputFile, 3*time.Second)
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
output := string(content)
|
||||
if !strings.Contains(output, "GOPEED_EVENT=DOWNLOAD_DONE") {
|
||||
t.Errorf("Expected GOPEED_EVENT in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GOPEED_TASK_ID="+task.ID) {
|
||||
t.Errorf("Expected GOPEED_TASK_ID in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GOPEED_TASK_NAME="+task.Name()) {
|
||||
t.Errorf("Expected GOPEED_TASK_NAME in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GOPEED_TASK_STATUS="+string(task.Status)) {
|
||||
t.Errorf("Expected GOPEED_TASK_STATUS in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GOPEED_TASK_PATH="+taskPath) {
|
||||
t.Errorf("Expected GOPEED_TASK_PATH in output, got: %s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package download
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
func TestScript_TriggerOnDone_MoveFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
||||
destDir := filepath.Join(tmpDir, "moved")
|
||||
task, taskPath := createDownloadDoneTask(t, downloadDir, "test.txt")
|
||||
srcPath := filepath.FromSlash(taskPath)
|
||||
destFile := filepath.Join(destDir, "test.txt")
|
||||
|
||||
scriptPath := getTestScriptPath(t, "move.bat")
|
||||
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: true,
|
||||
Paths: []string{scriptPath},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
t.Setenv("GOPEED_TEST_DEST_DIR", destDir)
|
||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
|
||||
waitForFile(t, destFile, 5*time.Second)
|
||||
if _, err := os.Stat(srcPath); !os.IsNotExist(err) {
|
||||
t.Errorf("Expected source file to be moved, but it still exists: %s", srcPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_MultipleScripts(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputFile1 := filepath.Join(tmpDir, "output1.txt")
|
||||
outputFile2 := filepath.Join(tmpDir, "output2.txt")
|
||||
scriptPath1 := getTestScriptPath(t, "write_output1.bat")
|
||||
scriptPath2 := getTestScriptPath(t, "write_output2.bat")
|
||||
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: true,
|
||||
Paths: []string{scriptPath1, scriptPath2},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
||||
task, _ := createDownloadDoneTask(t, downloadDir, "multi.txt")
|
||||
|
||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE_1", outputFile1)
|
||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE_2", outputFile2)
|
||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
|
||||
waitForFile(t, outputFile1, 5*time.Second)
|
||||
waitForFile(t, outputFile2, 5*time.Second)
|
||||
})
|
||||
}
|
||||
|
||||
func TestScript_EnvironmentVariables(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := getTestScriptPath(t, "env_dump.bat")
|
||||
outputFile := filepath.Join(tmpDir, "env_output.txt")
|
||||
|
||||
setupScriptTest(t, func(downloader *Downloader) {
|
||||
cfg, _ := downloader.GetConfig()
|
||||
cfg.Script = &base.ScriptConfig{
|
||||
Enable: true,
|
||||
Paths: []string{scriptPath},
|
||||
}
|
||||
downloader.PutConfig(cfg)
|
||||
|
||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
||||
task, taskPath := createDownloadDoneTask(t, downloadDir, "env.txt")
|
||||
|
||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile)
|
||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
|
||||
waitForFile(t, outputFile, 5*time.Second)
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
output := string(content)
|
||||
if !strings.Contains(output, "GOPEED_EVENT=DOWNLOAD_DONE") {
|
||||
t.Errorf("Expected GOPEED_EVENT in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GOPEED_TASK_ID="+task.ID) {
|
||||
t.Errorf("Expected GOPEED_TASK_ID in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GOPEED_TASK_NAME="+task.Name()) {
|
||||
t.Errorf("Expected GOPEED_TASK_NAME in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GOPEED_TASK_STATUS="+string(task.Status)) {
|
||||
t.Errorf("Expected GOPEED_TASK_STATUS in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "GOPEED_TASK_PATH="+taskPath) {
|
||||
t.Errorf("Expected GOPEED_TASK_PATH in output, got: %s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
if "%GOPEED_TEST_OUTPUT_FILE%"=="" exit /b 2
|
||||
echo GOPEED_EVENT=%GOPEED_EVENT% > "%GOPEED_TEST_OUTPUT_FILE%"
|
||||
echo GOPEED_TASK_ID=%GOPEED_TASK_ID% >> "%GOPEED_TEST_OUTPUT_FILE%"
|
||||
echo GOPEED_TASK_NAME=%GOPEED_TASK_NAME% >> "%GOPEED_TEST_OUTPUT_FILE%"
|
||||
echo GOPEED_TASK_STATUS=%GOPEED_TASK_STATUS% >> "%GOPEED_TEST_OUTPUT_FILE%"
|
||||
echo GOPEED_TASK_PATH=%GOPEED_TASK_PATH% >> "%GOPEED_TEST_OUTPUT_FILE%"
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ -z "$GOPEED_TEST_OUTPUT_FILE" ]; then
|
||||
echo "GOPEED_TEST_OUTPUT_FILE is empty" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "GOPEED_EVENT=$GOPEED_EVENT" > "$GOPEED_TEST_OUTPUT_FILE"
|
||||
echo "GOPEED_TASK_ID=$GOPEED_TASK_ID" >> "$GOPEED_TEST_OUTPUT_FILE"
|
||||
echo "GOPEED_TASK_NAME=$GOPEED_TASK_NAME" >> "$GOPEED_TEST_OUTPUT_FILE"
|
||||
echo "GOPEED_TASK_STATUS=$GOPEED_TASK_STATUS" >> "$GOPEED_TEST_OUTPUT_FILE"
|
||||
echo "GOPEED_TASK_PATH=$GOPEED_TASK_PATH" >> "$GOPEED_TEST_OUTPUT_FILE"
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
if "%GOPEED_TEST_DEST_DIR%"=="" exit /b 2
|
||||
set "SRC=%GOPEED_TASK_PATH:/=\%"
|
||||
set "DEST=%GOPEED_TEST_DEST_DIR%"
|
||||
if not exist "%DEST%" mkdir "%DEST%"
|
||||
move /Y "%SRC%" "%DEST%\" >nul
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ -z "$GOPEED_TEST_DEST_DIR" ]; then
|
||||
echo "GOPEED_TEST_DEST_DIR is empty" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$GOPEED_TEST_DEST_DIR"
|
||||
mv "$GOPEED_TASK_PATH" "$GOPEED_TEST_DEST_DIR/"
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
if "%GOPEED_TEST_OUTPUT_FILE_1%"=="" exit /b 2
|
||||
echo Script 1 > "%GOPEED_TEST_OUTPUT_FILE_1%"
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ -z "$GOPEED_TEST_OUTPUT_FILE_1" ]; then
|
||||
echo "GOPEED_TEST_OUTPUT_FILE_1 is empty" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "Script 1" > "$GOPEED_TEST_OUTPUT_FILE_1"
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
if "%GOPEED_TEST_OUTPUT_FILE_2%"=="" exit /b 2
|
||||
echo Script 2 > "%GOPEED_TEST_OUTPUT_FILE_2%"
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ -z "$GOPEED_TEST_OUTPUT_FILE_2" ]; then
|
||||
echo "GOPEED_TEST_OUTPUT_FILE_2 is empty" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "Script 2" > "$GOPEED_TEST_OUTPUT_FILE_2"
|
||||
@@ -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(
|
||||
|
||||
@@ -10,6 +10,7 @@ class DownloaderConfig {
|
||||
ExtraConfig extra = ExtraConfig();
|
||||
ProxyConfig proxy = ProxyConfig();
|
||||
WebhookConfig webhook = WebhookConfig();
|
||||
ScriptConfig script = ScriptConfig();
|
||||
AutoTorrentConfig autoTorrent = AutoTorrentConfig();
|
||||
ArchiveConfig archive = ArchiveConfig();
|
||||
bool autoDeleteMissingFileTasks;
|
||||
@@ -149,6 +150,22 @@ class WebhookConfig {
|
||||
Map<String, dynamic> toJson() => _$WebhookConfigToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ScriptConfig {
|
||||
bool enable;
|
||||
List<String> paths;
|
||||
|
||||
ScriptConfig({
|
||||
this.enable = false,
|
||||
this.paths = const [],
|
||||
});
|
||||
|
||||
factory ScriptConfig.fromJson(Map<String, dynamic>? json) =>
|
||||
json == null ? ScriptConfig() : _$ScriptConfigFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ScriptConfigToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ProxyConfig {
|
||||
bool enable;
|
||||
|
||||
@@ -19,6 +19,8 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
|
||||
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
|
||||
..webhook =
|
||||
WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
|
||||
..script =
|
||||
ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
|
||||
..autoTorrent = AutoTorrentConfig.fromJson(
|
||||
json['autoTorrent'] as Map<String, dynamic>?)
|
||||
..archive =
|
||||
@@ -32,6 +34,7 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
|
||||
'extra': instance.extra.toJson(),
|
||||
'proxy': instance.proxy.toJson(),
|
||||
'webhook': instance.webhook.toJson(),
|
||||
'script': instance.script.toJson(),
|
||||
'autoTorrent': instance.autoTorrent.toJson(),
|
||||
'archive': instance.archive.toJson(),
|
||||
'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks,
|
||||
@@ -153,6 +156,20 @@ Map<String, dynamic> _$WebhookConfigToJson(WebhookConfig instance) =>
|
||||
'urls': instance.urls,
|
||||
};
|
||||
|
||||
ScriptConfig _$ScriptConfigFromJson(Map<String, dynamic> json) =>
|
||||
ScriptConfig(
|
||||
enable: json['enable'] as bool? ?? false,
|
||||
paths:
|
||||
(json['paths'] as List<dynamic>?)?.map((e) => e as String).toList() ??
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ScriptConfigToJson(ScriptConfig instance) =>
|
||||
<String, dynamic>{
|
||||
'enable': instance.enable,
|
||||
'paths': instance.paths,
|
||||
};
|
||||
|
||||
ProxyConfig _$ProxyConfigFromJson(Map<String, dynamic> json) => ProxyConfig(
|
||||
enable: json['enable'] as bool? ?? false,
|
||||
system: json['system'] as bool? ?? false,
|
||||
|
||||
@@ -59,14 +59,31 @@ 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;
|
||||
|
||||
/// Command line --hidden flag passed from main.dart
|
||||
final bool hiddenFromArgs;
|
||||
|
||||
AppController({this.hiddenFromArgs = false});
|
||||
|
||||
final autoStartup = false.obs;
|
||||
final startConfig = StartConfig().obs;
|
||||
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;
|
||||
|
||||
@@ -156,24 +173,44 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
|
||||
Future<void> _initDeepLinks() async {
|
||||
if (Util.isWeb()) {
|
||||
// For web, just show window
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle deep link
|
||||
() async {
|
||||
_appLinks = AppLinks();
|
||||
_appLinks = AppLinks();
|
||||
|
||||
// Handle link when app is in warm state (front or background)
|
||||
_linkSubscription = _appLinks.uriLinkStream.listen((uri) async {
|
||||
await _handleDeepLink(uri);
|
||||
});
|
||||
// Handle link when app is in warm state (front or background)
|
||||
_linkSubscription = _appLinks.uriLinkStream.listen((uri) async {
|
||||
await _handleDeepLink(uri);
|
||||
});
|
||||
|
||||
// Check initial link if app was in cold state (terminated)
|
||||
final uri = await _appLinks.getInitialLink();
|
||||
if (uri != null) {
|
||||
await _handleDeepLink(uri);
|
||||
}
|
||||
}();
|
||||
// Get initial link for cold start - this works after runApp()
|
||||
Uri? initialLink;
|
||||
try {
|
||||
initialLink = await _appLinks.getInitialLink();
|
||||
} catch (e) {
|
||||
// ignore errors
|
||||
}
|
||||
|
||||
// Determine if window should be hidden
|
||||
// Priority 1: gopeed: URL scheme hidden parameter
|
||||
// Priority 2: Command line --hidden flag
|
||||
bool shouldHide = hiddenFromArgs;
|
||||
if (initialLink?.scheme == "gopeed") {
|
||||
shouldHide = initialLink!.queryParameters["hidden"] == "true";
|
||||
}
|
||||
|
||||
// Show window if not hidden (desktop only)
|
||||
if (Util.isDesktop() && !shouldHide) {
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
}
|
||||
|
||||
// Handle initial link for deep link navigation
|
||||
if (initialLink != null) {
|
||||
_handleDeepLink(initialLink);
|
||||
}
|
||||
|
||||
// Handle shared media, e.g. shared link from browser
|
||||
if (Util.isMobile()) {
|
||||
@@ -295,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 {
|
||||
@@ -638,7 +675,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
launchAtStartup.setup(
|
||||
appName: packageInfo.appName,
|
||||
appPath: Platform.resolvedExecutable,
|
||||
args: ['--${Args.flagHidden}']);
|
||||
args: ['--${StartupArgs.flagHidden}']);
|
||||
autoStartup.value = await launchAtStartup.isEnabled();
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,11 @@ class AppView extends GetView<AppController> {
|
||||
// Set the title bar to use the actual brightness of the UI
|
||||
windowManager.setBrightness(brightness);
|
||||
}
|
||||
return child!;
|
||||
// Fix for GetX Overlay issue with Flutter 3.38.1+
|
||||
// Reference: https://github.com/jonataslaw/getx/issues/3425
|
||||
return Overlay(
|
||||
initialEntries: [OverlayEntry(builder: (_) => child!)],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -38,7 +38,7 @@ class ExtensionView extends GetView<ExtensionController> {
|
||||
_installBtnController.start();
|
||||
try {
|
||||
await installExtension(InstallExtension(url: _installUrlController.text));
|
||||
Get.snackbar('tip'.tr, 'extensionInstallSuccess'.tr);
|
||||
showMessage('tip'.tr, 'extensionInstallSuccess'.tr);
|
||||
await controller.load();
|
||||
} catch (e) {
|
||||
showErrorMessage(e);
|
||||
@@ -109,7 +109,7 @@ class ExtensionView extends GetView<ExtensionController> {
|
||||
try {
|
||||
await installExtension(
|
||||
InstallExtension(devMode: true, url: dir));
|
||||
Get.snackbar(
|
||||
showMessage(
|
||||
'tip'.tr, 'extensionInstallSuccess'.tr);
|
||||
await controller.load();
|
||||
} catch (e) {
|
||||
@@ -290,7 +290,7 @@ class ExtensionView extends GetView<ExtensionController> {
|
||||
return showDialog<void>(
|
||||
context: Get.context!,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => AlertDialog(
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
content: Builder(builder: (context) {
|
||||
final height = MediaQuery.of(context).size.height;
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
@@ -351,7 +351,7 @@ class ExtensionView extends GetView<ExtensionController> {
|
||||
backgroundColor: MaterialStateProperty.all(
|
||||
Get.theme.colorScheme.background)),
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
child: Text('cancel'.tr),
|
||||
),
|
||||
@@ -372,7 +372,9 @@ class ExtensionView extends GetView<ExtensionController> {
|
||||
UpdateExtensionSettings(
|
||||
settings: formKey.currentState!.value));
|
||||
await controller.load();
|
||||
Get.back();
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorMessage(e);
|
||||
@@ -453,12 +455,12 @@ class ExtensionView extends GetView<ExtensionController> {
|
||||
showDialog(
|
||||
context: Get.context!,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => AlertDialog(
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text('extensionDelete'.tr),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text('cancel'.tr),
|
||||
onPressed: () => Get.back(),
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
),
|
||||
TextButton(
|
||||
child: Text(
|
||||
@@ -469,7 +471,9 @@ class ExtensionView extends GetView<ExtensionController> {
|
||||
try {
|
||||
await deleteExtension(extension.identity);
|
||||
await controller.load();
|
||||
Get.back();
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorMessage(e);
|
||||
}
|
||||
@@ -484,14 +488,14 @@ class ExtensionView extends GetView<ExtensionController> {
|
||||
|
||||
showDialog(
|
||||
context: Get.context!,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
content: Text('newVersionTitle'.trParams({
|
||||
'version': 'v${controller.updateFlags[extension.identity]!}'
|
||||
})),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
child: Text('newVersionLater'.tr),
|
||||
),
|
||||
@@ -503,7 +507,9 @@ class ExtensionView extends GetView<ExtensionController> {
|
||||
await updateExtension(extension.identity);
|
||||
await controller.load();
|
||||
controller.updateFlags.remove(extension.identity);
|
||||
Get.back();
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
showMessage('tip'.tr, 'extensionUpdateSuccess'.tr);
|
||||
} catch (e) {
|
||||
showErrorMessage(e);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:badges/badges.dart' as badges;
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -1432,6 +1433,92 @@ class SettingView extends GetView<SettingController> {
|
||||
},
|
||||
);
|
||||
|
||||
// advanced config script items
|
||||
final buildScript = _buildConfigItem(
|
||||
'script',
|
||||
() => downloaderCfg.value.script.enable ? 'on'.tr : 'off'.tr,
|
||||
(Key key) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'scriptEnable'.tr,
|
||||
style: Theme.of(Get.context!).textTheme.bodyMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
Switch(
|
||||
value: downloaderCfg.value.script.enable,
|
||||
onChanged: (value) {
|
||||
downloaderCfg.update((val) {
|
||||
val!.script.enable = value;
|
||||
});
|
||||
debounceSave();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
_padding,
|
||||
Text(
|
||||
'scriptDesc'.tr,
|
||||
style: Theme.of(Get.context!).textTheme.bodySmall,
|
||||
),
|
||||
_padding,
|
||||
// List of existing script paths
|
||||
...downloaderCfg.value.script.paths.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final path = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
path,
|
||||
style: Theme.of(Get.context!).textTheme.bodyMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, size: 20),
|
||||
tooltip: 'edit'.tr,
|
||||
onPressed: () {
|
||||
_showScriptDialog(index: index, initialPath: path);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, size: 20),
|
||||
tooltip: 'delete'.tr,
|
||||
onPressed: () async {
|
||||
// Create new list to avoid unmodifiable list error
|
||||
final paths =
|
||||
List<String>.from(downloaderCfg.value.script.paths);
|
||||
paths.removeAt(index);
|
||||
downloaderCfg.update((val) {
|
||||
val!.script.paths = paths;
|
||||
});
|
||||
await debounceSave();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
_padding,
|
||||
// Add button
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
_showScriptDialog();
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text('add'.tr),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// advanced config log items start
|
||||
buildLogsDir() {
|
||||
return ListTile(
|
||||
@@ -1603,6 +1690,7 @@ class SettingView extends GetView<SettingController> {
|
||||
child: Column(
|
||||
children: _addDivider([
|
||||
buildWebhook(),
|
||||
if (Util.isDesktop()) buildScript(),
|
||||
buildLogsDir(),
|
||||
]),
|
||||
)),
|
||||
@@ -1734,6 +1822,104 @@ class SettingView extends GetView<SettingController> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showScriptDialog({int? index, String? initialPath}) {
|
||||
final pathController = TextEditingController(text: initialPath ?? '');
|
||||
final saveController = TextButtonLoadingController();
|
||||
final appController = Get.find<AppController>();
|
||||
final downloaderCfg = appController.downloaderConfig;
|
||||
final isEdit = index != null;
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
showDialog(
|
||||
context: Get.context!,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(isEdit ? 'edit'.tr : 'add'.tr),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: pathController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'scriptPathHint'.tr,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'required'.tr;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
if (Util.isDesktop())
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.folder_open),
|
||||
onPressed: () async {
|
||||
final result = await FilePicker.platform.pickFiles();
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
final filePath = result.files.first.path;
|
||||
if (filePath != null) {
|
||||
pathController.text = filePath;
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text('cancel'.tr),
|
||||
),
|
||||
TextButtonLoading(
|
||||
controller: saveController,
|
||||
onPressed: () async {
|
||||
if (!formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
final path = pathController.text.trim();
|
||||
if (path.isEmpty) return;
|
||||
|
||||
saveController.start();
|
||||
try {
|
||||
// Create new list to avoid unmodifiable list error
|
||||
final paths =
|
||||
List<String>.from(downloaderCfg.value.script.paths);
|
||||
if (isEdit) {
|
||||
paths[index] = path;
|
||||
} else {
|
||||
paths.add(path);
|
||||
}
|
||||
downloaderCfg.update((val) {
|
||||
val!.script.paths = paths;
|
||||
});
|
||||
await appController.saveConfig();
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorMessage(e);
|
||||
} finally {
|
||||
saveController.stop();
|
||||
}
|
||||
},
|
||||
child: Text('confirm'.tr),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showGithubMirrorDialog({int? index, GithubMirror? initialMirror}) {
|
||||
final isEdit = index != null;
|
||||
GithubMirrorType selectedType =
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -80,6 +80,10 @@ const enUS = {
|
||||
'webhookTest': 'Test',
|
||||
'webhookTestSuccess': 'Webhook test successful',
|
||||
'webhookTestFail': 'Webhook test failed',
|
||||
'script': 'Script',
|
||||
'scriptEnable': 'Enable Script',
|
||||
'scriptDesc': 'Execute custom scripts when downloads complete successfully',
|
||||
'scriptPathHint': 'Enter script file path (e.g., /path/to/script.sh)',
|
||||
'urlInvalid': 'Please enter a valid HTTP or HTTPS URL',
|
||||
'required': 'This field is required',
|
||||
'show': 'Show',
|
||||
@@ -147,6 +151,7 @@ const enUS = {
|
||||
'unknown': 'Unknown',
|
||||
'fileSelectedCount': 'Files: ',
|
||||
'fileSelectedSize': 'Size: ',
|
||||
'httpHeader': 'Header',
|
||||
'httpHeaderName': 'Header Name',
|
||||
'httpHeaderValue': 'Header Value',
|
||||
'login': 'Login',
|
||||
@@ -177,5 +182,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',
|
||||
},
|
||||
};
|
||||
@@ -78,6 +78,10 @@ const zhCN = {
|
||||
'webhookTest': '测试',
|
||||
'webhookTestSuccess': 'Webhook 测试成功',
|
||||
'webhookTestFail': 'Webhook 测试失败',
|
||||
'script': '脚本执行',
|
||||
'scriptEnable': '启用脚本',
|
||||
'scriptDesc': '在下载成功完成时执行自定义脚本',
|
||||
'scriptPathHint': '请输入脚本文件路径(例如:/path/to/script.sh)',
|
||||
'urlInvalid': '请输入有效的 HTTP 或 HTTPS 链接',
|
||||
'required': '此项为必填项',
|
||||
'show': '显示',
|
||||
@@ -144,6 +148,7 @@ const zhCN = {
|
||||
'unknown': '未知',
|
||||
'fileSelectedCount': '文件数:',
|
||||
'fileSelectedSize': '大小:',
|
||||
'httpHeader': '请求头',
|
||||
'httpHeaderName': '请求头名称',
|
||||
'httpHeaderValue': '请求头值',
|
||||
'login': '登录',
|
||||
@@ -173,5 +178,15 @@ const zhCN = {
|
||||
'githubMirrorType': '镜像类型',
|
||||
'githubMirrorUrl': '镜像地址',
|
||||
'githubMirrorUrlHint': '请输入镜像地址',
|
||||
'updateUrl': '更新地址',
|
||||
'updateUrlManual': '手动更新',
|
||||
'updateUrlListen': '监听更新',
|
||||
'updateUrlListeningTip': '正在等待更新地址',
|
||||
'updateUrlCancelListen': '取消监听',
|
||||
'updateUrlDialogHint': '请输入新的下载地址',
|
||||
'pendingUpdateFound': '发现待更新任务',
|
||||
'pendingUpdateConfirm': '任务 "@name" 正在等待更新地址,是否使用新地址更新该任务?',
|
||||
'pendingUpdateYes': '更新任务',
|
||||
'pendingUpdateNo': '创建新任务',
|
||||
}
|
||||
};
|
||||
+22
-35
@@ -22,37 +22,32 @@ import 'util/scheme_register/scheme_register.dart';
|
||||
import 'util/updater.dart';
|
||||
import 'util/util.dart';
|
||||
|
||||
class Args {
|
||||
class StartupArgs {
|
||||
static const flagHidden = "hidden";
|
||||
|
||||
bool hidden = false;
|
||||
/// Command line --hidden flag (for auto-start)
|
||||
bool hiddenFromArgs = false;
|
||||
|
||||
Args();
|
||||
StartupArgs._();
|
||||
|
||||
Args.parse(List<String> args) {
|
||||
final parser = ArgParser();
|
||||
parser.addFlag(flagHidden);
|
||||
final results = parser.parse(args);
|
||||
hidden = results.flag(flagHidden);
|
||||
/// Parse from command line arguments only
|
||||
static StartupArgs parse(List<String> arguments) {
|
||||
final args = StartupArgs._();
|
||||
try {
|
||||
final parser = ArgParser()..addFlag(flagHidden);
|
||||
final results = parser.parse(arguments);
|
||||
args.hiddenFromArgs = results.flag(flagHidden);
|
||||
} catch (e) {
|
||||
// ignore parse errors
|
||||
}
|
||||
return args;
|
||||
}
|
||||
}
|
||||
|
||||
void main(List<String> arguments) async {
|
||||
Args args;
|
||||
// Parse url scheme arguments, e.g. gopeed:?hidden=true
|
||||
// TODO: macos open url handle
|
||||
// TODO: macos updater test
|
||||
if (arguments.firstOrNull?.startsWith("gopeed:") == true) {
|
||||
try {
|
||||
final uri = Uri.parse(arguments.first);
|
||||
args = Args()..hidden = uri.queryParameters["hidden"] == "true";
|
||||
} catch (e) {
|
||||
// ignore
|
||||
args = Args.parse([]);
|
||||
}
|
||||
} else {
|
||||
args = Args.parse(arguments);
|
||||
}
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final args = StartupArgs.parse(arguments);
|
||||
|
||||
await init(args);
|
||||
onStart();
|
||||
@@ -60,8 +55,8 @@ void main(List<String> arguments) async {
|
||||
runApp(const AppView());
|
||||
}
|
||||
|
||||
Future<void> init(Args args) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
Future<void> init(StartupArgs args) async {
|
||||
// Note: WidgetsFlutterBinding.ensureInitialized() is already called in main()
|
||||
if (Util.isMobile()) {
|
||||
FlutterForegroundTask.initCommunicationPort();
|
||||
}
|
||||
@@ -81,16 +76,7 @@ Future<void> init(Args args) async {
|
||||
skipTaskbar: runAsMenubarApp,
|
||||
);
|
||||
await windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
if (!args.hidden) {
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
}
|
||||
await windowManager.setPreventClose(true);
|
||||
// windows_manager has a bug where when window to be maximized, it will be unmaximized immediately, so can't implement this feature currently.
|
||||
// https://github.com/leanflutter/window_manager/issues/412
|
||||
// if (windowState.isMaximized) {
|
||||
// await windowManager.maximize();
|
||||
// }
|
||||
});
|
||||
|
||||
// Register Cmd+W hotkey on macOS to close window
|
||||
@@ -118,7 +104,8 @@ Future<void> init(Args args) async {
|
||||
logger.e("init package info fail", e);
|
||||
}
|
||||
|
||||
final controller = Get.put(AppController());
|
||||
final controller =
|
||||
Get.put(AppController(hiddenFromArgs: args.hiddenFromArgs));
|
||||
try {
|
||||
await controller.loadStartConfig();
|
||||
final startCfg = controller.startConfig.value;
|
||||
|
||||
+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:
|
||||
|
||||
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
version: 1.9.0+1
|
||||
version: 1.9.1+1
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
@@ -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
|
||||
|
||||
@@ -65,6 +65,11 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
|
||||
flutter::DartProject project(L"data");
|
||||
|
||||
// TODO: Remove this.
|
||||
// This forces Flutter to use a separate thread for Dart.
|
||||
// This mode will be removed in a future version of Flutter.
|
||||
project.set_ui_thread_policy(flutter::UIThreadPolicy::RunOnSeparateThread);
|
||||
|
||||
std::vector<std::string> command_line_arguments =
|
||||
GetCommandLineArguments();
|
||||
|
||||
|
||||
Reference in new issue
Block a user