mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-11 10:54:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b0254f7c0 | ||
|
|
d36b345f92 | ||
|
|
bc5c818eae | ||
|
|
98bd3305b7 | ||
|
|
3f38848051 | ||
|
|
82d7f8ec81 | ||
|
|
a6981d58e9 | ||
|
|
1b1631b41a | ||
|
|
3eb6df55df | ||
|
|
6de63c3f63 | ||
|
|
969615f353 | ||
|
|
5a88b98314 | ||
|
|
2674b1c39e | ||
|
|
4c90b38247 | ||
|
|
93088b22f1 | ||
|
|
fe90c1d261 |
No files matched your search
@@ -14,7 +14,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.24"
|
GO_VERSION: "1.24"
|
||||||
FLUTTER_VERSION: "3.38.7"
|
FLUTTER_VERSION: "3.41.2"
|
||||||
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||||
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
|
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
|
||||||
|
|
||||||
@@ -44,15 +44,17 @@ jobs:
|
|||||||
- arch: arm64
|
- arch: arm64
|
||||||
os: windows-11-arm
|
os: windows-11-arm
|
||||||
llvm_ver: "20251202" # Only ARM64
|
llvm_ver: "20251202" # Only ARM64
|
||||||
flutter_channel: "master"
|
flutter_channel: "main"
|
||||||
flutter_version: "8817d45220d" # Only ARM64
|
flutter_version: "7e1c8868"
|
||||||
- arch: amd64
|
- arch: amd64
|
||||||
os: windows-2022
|
os: windows-2022
|
||||||
flutter_channel: "stable"
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- 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
|
# Install LLVM environment only on ARM64
|
||||||
- name: Install llvm-mingw-ucrt-aarch64 (ARM64 only)
|
- name: Install llvm-mingw-ucrt-aarch64 (ARM64 only)
|
||||||
@@ -84,7 +86,7 @@ jobs:
|
|||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: ${{ matrix.flutter_channel }}
|
channel: ${{ matrix.flutter_channel || 'stable' }}
|
||||||
flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}
|
flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}
|
||||||
- name: Build
|
- name: Build
|
||||||
env:
|
env:
|
||||||
@@ -112,6 +114,7 @@ jobs:
|
|||||||
$release = "build\windows\arm64\runner\Release\"
|
$release = "build\windows\arm64\runner\Release\"
|
||||||
$mingw = "$env:CLANGARM64_ROOT\aarch64-w64-mingw32\bin"
|
$mingw = "$env:CLANGARM64_ROOT\aarch64-w64-mingw32\bin"
|
||||||
cp $mingw\libc++.dll $release
|
cp $mingw\libc++.dll $release
|
||||||
|
cp $mingw\libunwind.dll $release
|
||||||
}
|
}
|
||||||
cp $mingw\libwinpthread-1.dll $release
|
cp $mingw\libwinpthread-1.dll $release
|
||||||
cp $system\msvcp140.dll $release
|
cp $system\msvcp140.dll $release
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.24"
|
GO_VERSION: "1.24"
|
||||||
FLUTTER_VERSION: "3.38.7"
|
FLUTTER_VERSION: "3.41.2"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# lint:
|
# lint:
|
||||||
@@ -66,21 +66,56 @@ jobs:
|
|||||||
files: ./coverage.txt
|
files: ./coverage.txt
|
||||||
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
||||||
build-desktop:
|
build-desktop:
|
||||||
name: Build desktop check
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
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]
|
needs: [test]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- 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
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
channel: ${{ matrix.flutter_channel || 'stable' }}
|
||||||
channel: master
|
flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}
|
||||||
- if: runner.os == 'Windows'
|
- if: runner.os == 'Windows'
|
||||||
run: |
|
run: |
|
||||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop
|
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
|
WORKDIR /app
|
||||||
COPY ./go.mod ./go.sum ./
|
COPY ./go.mod ./go.sum ./
|
||||||
RUN go mod download
|
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" \
|
-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
|
-o dist/gopeed github.com/GopeedLab/gopeed/cmd/web
|
||||||
|
|
||||||
FROM alpine:3.18
|
FROM alpine:3.23
|
||||||
LABEL maintainer="monkeyWie"
|
LABEL maintainer="monkeyWie"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=go /app/dist/gopeed ./
|
COPY --from=go /app/dist/gopeed ./
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# [](https://gopeed.com)
|
# [](https://gopeed.com)
|
||||||
|
|
||||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# [](https://gopeed.com)
|
# [](https://gopeed.com)
|
||||||
|
|
||||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# [](https://gopeed.com)
|
# [](https://gopeed.com)
|
||||||
|
|
||||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# [](https://gopeed.com)
|
# [](https://gopeed.com)
|
||||||
|
|
||||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# [](https://gopeed.com)
|
# [](https://gopeed.com)
|
||||||
|
|
||||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,54 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 400" width="1200" height="400">
|
||||||
|
<rect width="1200" height="400" fill="#040605"/>
|
||||||
|
<g fill="#5dc76c">
|
||||||
|
<rect x="-100" y="50" width="150" height="2" opacity="0.6">
|
||||||
|
<animate attributeName="x" from="-200" to="1300" dur="0.8s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
<rect x="-200" y="150" width="200" height="1" opacity="0.4">
|
||||||
|
<animate attributeName="x" from="-300" to="1300" dur="1.2s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
<rect x="-50" y="250" width="180" height="3" opacity="0.8">
|
||||||
|
<animate attributeName="x" from="-200" to="1300" dur="0.6s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
<rect x="-150" y="350" width="220" height="2" opacity="0.5">
|
||||||
|
<animate attributeName="x" from="-300" to="1300" dur="1s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
<rect x="-100" y="100" width="100" height="1" opacity="0.7">
|
||||||
|
<animate attributeName="x" from="-150" to="1300" dur="0.9s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
<rect x="-250" y="300" width="250" height="2" opacity="0.3">
|
||||||
|
<animate attributeName="x" from="-400" to="1300" dur="1.5s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="logoGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="12" result="blur" />
|
||||||
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate(600, 160)">
|
||||||
|
<!-- Floating animation -->
|
||||||
|
<g>
|
||||||
|
<animateTransform attributeName="transform" type="translate" values="0,-8; 0,8; 0,-8" dur="3s" repeatCount="indefinite" />
|
||||||
|
|
||||||
|
<!-- Pulsing glow behind logo -->
|
||||||
|
<circle cx="0" cy="0" r="45" fill="#5dc76c" filter="url(#logoGlow)" opacity="0.3">
|
||||||
|
<animate attributeName="opacity" values="0.1; 0.6; 0.1" dur="2s" repeatCount="indefinite" />
|
||||||
|
<animate attributeName="r" values="40; 65; 40" dur="2s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<!-- Breathing scale animation -->
|
||||||
|
<g>
|
||||||
|
<animateTransform attributeName="transform" type="scale" values="0.95; 1.05; 0.95" dur="2s" repeatCount="indefinite" />
|
||||||
|
<g transform="scale(0.12) translate(-512, -512)">
|
||||||
|
<path style="opacity:0.998" fill="#5dc76c" d="M 951.5,763.5 C 940.739,755.276 929.405,747.776 917.5,741C 908.477,736.319 899.143,732.485 889.5,729.5C 885.844,625.779 840.844,545.613 754.5,489C 674.802,441.453 590.135,430.12 500.5,455C 419.755,480.605 359.588,530.771 320,605.5C 278.585,565.816 230.752,555.649 176.5,575C 131.948,594.935 106.448,629.102 100,677.5C 99.3333,685.833 99.3333,694.167 100,702.5C 102.081,714.824 105.081,726.824 109,738.5C 95.4882,745.492 82.9882,753.826 71.5,763.5C 37.7121,705.639 16.5454,643.306 8,576.5C -9.41698,416.759 35.9164,278.093 144,160.5C 244.661,58.2827 367.161,5.94939 511.5,3.5C 678.975,7.48829 814.142,74.8216 917,205.5C 1011.33,336.426 1040,480.759 1003,638.5C 991.564,682.671 974.397,724.337 951.5,763.5 Z"/>
|
||||||
|
<path style="opacity:0.999" fill="#fefefe" d="M 951.5,763.5 C 949.28,768.272 946.78,772.938 944,777.5C 938.305,786.913 932.138,795.913 925.5,804.5C 915.954,796.888 905.288,791.055 893.5,787C 878.282,782.958 863.115,778.791 848,774.5C 854.013,716.891 841.346,663.891 810,615.5C 768.341,553.228 711.174,512.395 638.5,493C 560.311,479.566 490.311,497.566 428.5,547C 411.963,560.536 397.13,575.703 384,592.5C 366.745,616.923 348.911,640.923 330.5,664.5C 310.135,651.302 290.469,637.135 271.5,622C 238.448,602.307 206.782,604.307 176.5,628C 159.375,642.752 148.875,661.252 145,683.5C 143.968,692.716 144.968,701.716 148,710.5C 154.334,725.339 160.167,740.339 165.5,755.5C 138.118,764.688 114.451,779.688 94.5,800.5C 85.653,788.968 77.9863,776.635 71.5,763.5C 82.9882,753.826 95.4882,745.492 109,738.5C 105.081,726.824 102.081,714.824 100,702.5C 99.3333,694.167 99.3333,685.833 100,677.5C 106.448,629.102 131.948,594.935 176.5,575C 230.752,555.649 278.585,565.816 320,605.5C 359.588,530.771 419.755,480.605 500.5,455C 590.135,430.12 674.802,441.453 754.5,489C 840.844,545.613 885.844,625.779 889.5,729.5C 899.143,732.485 908.477,736.319 917.5,741C 929.405,747.776 940.739,755.276 951.5,763.5 Z"/>
|
||||||
|
<path style="opacity:0.998" fill="#5dc76c" d="M 925.5,804.5 C 850.791,908.608 750.791,975.775 625.5,1006C 609.308,1009.7 592.975,1012.53 576.5,1014.5C 615.708,969.938 655.042,925.438 694.5,881C 664.168,880.5 633.835,880.333 603.5,880.5C 603.5,811.167 603.5,741.833 603.5,672.5C 542.167,672.5 480.833,672.5 419.5,672.5C 419.5,741.5 419.5,810.5 419.5,879.5C 388.493,879.167 357.493,879.5 326.5,880.5C 326.624,881.107 326.957,881.44 327.5,881.5C 366.805,925.808 406.138,970.141 445.5,1014.5C 318.63,996.317 212.796,939.651 128,844.5C 115.85,830.525 104.683,815.859 94.5,800.5C 114.451,779.688 138.118,764.688 165.5,755.5C 160.167,740.339 154.334,725.339 148,710.5C 144.968,701.716 143.968,692.716 145,683.5C 148.875,661.252 159.375,642.752 176.5,628C 206.782,604.307 238.448,602.307 271.5,622C 290.469,637.135 310.135,651.302 330.5,664.5C 348.911,640.923 366.745,616.923 384,592.5C 397.13,575.703 411.963,560.536 428.5,547C 490.311,497.566 560.311,479.566 638.5,493C 711.174,512.395 768.341,553.228 810,615.5C 841.346,663.891 854.013,716.891 848,774.5C 863.115,778.791 878.282,782.958 893.5,787C 905.288,791.055 915.954,796.888 925.5,804.5 Z"/>
|
||||||
|
<path style="opacity:0.999" fill="#fefefe" d="M 576.5,1014.5 C 532.836,1019.82 489.169,1019.82 445.5,1014.5C 406.138,970.141 366.805,925.808 327.5,881.5C 358.167,880.833 388.833,880.167 419.5,879.5C 419.5,810.5 419.5,741.5 419.5,672.5C 480.833,672.5 542.167,672.5 603.5,672.5C 603.5,741.833 603.5,811.167 603.5,880.5C 633.835,880.333 664.168,880.5 694.5,881C 655.042,925.438 615.708,969.938 576.5,1014.5 Z"/>
|
||||||
|
<path style="opacity:1" fill="#ade2b5" d="M 419.5,879.5 C 388.833,880.167 358.167,880.833 327.5,881.5C 326.957,881.44 326.624,881.107 326.5,880.5C 357.493,879.5 388.493,879.167 419.5,879.5 Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="300" font-family="sans-serif" font-size="72" font-style="italic" font-weight="900" text-anchor="middle" fill="#fff" letter-spacing="8">GOPEED</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 1.2 MiB |
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
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/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 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
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=
|
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-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-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-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 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
|
||||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
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=
|
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.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.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.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 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
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=
|
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)
|
Setup(ctl *controller.Controller)
|
||||||
Resolve(req *base.Request, opts *base.Options) error
|
Resolve(req *base.Request, opts *base.Options) error
|
||||||
Start() 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
|
Pause() error
|
||||||
Close() error
|
Close() error
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,59 @@ func (f *Fetcher) isDone() bool {
|
|||||||
return true
|
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() {
|
func (f *Fetcher) updateRes() {
|
||||||
res := &base.Resource{
|
res := &base.Resource{
|
||||||
Range: true,
|
Range: true,
|
||||||
|
|||||||
@@ -239,3 +239,130 @@ func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher
|
|||||||
fetcher.Setup(newController)
|
fetcher.Setup(newController)
|
||||||
return fetcher
|
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
|
// Already downloading, this is a resume from pause
|
||||||
return f.doStart()
|
return f.doStart()
|
||||||
|
|
||||||
|
case stateError:
|
||||||
|
// Retry after error: reset and restart
|
||||||
|
return f.doStart()
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("cannot start in current state: %v", state)
|
return fmt.Errorf("cannot start in current state: %v", state)
|
||||||
}
|
}
|
||||||
@@ -598,10 +602,31 @@ func (f *Fetcher) doStart() error {
|
|||||||
<-f.resolvedCh
|
<-f.resolvedCh
|
||||||
|
|
||||||
state := f.getState()
|
state := f.getState()
|
||||||
if state == stateDone || state == stateError {
|
if state == stateDone {
|
||||||
return nil
|
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)
|
// Open or create target file first (needed for prefetch copy)
|
||||||
name := f.meta.SingleFilepath()
|
name := f.meta.SingleFilepath()
|
||||||
var err error
|
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.
|
// 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 {
|
func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf []byte) error {
|
||||||
if conn.ctx.Err() != nil {
|
if conn.ctx.Err() != nil {
|
||||||
return conn.ctx.Err()
|
return conn.ctx.Err()
|
||||||
@@ -981,11 +1008,34 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent {
|
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
|
// Record successful connection time for adaptive timeout
|
||||||
f.updateMaxConnTime(time.Since(connStartTime))
|
f.updateMaxConnTime(time.Since(connStartTime))
|
||||||
@@ -1523,6 +1573,60 @@ func (f *Fetcher) checkCompletion() bool {
|
|||||||
return false
|
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 {
|
func (f *Fetcher) Pause() error {
|
||||||
if f.cancel != nil {
|
if f.cancel != nil {
|
||||||
f.cancel()
|
f.cancel()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
type fetcherData struct {
|
type fetcherData struct {
|
||||||
Connections []*connection
|
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) {
|
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
||||||
_f := f.(*Fetcher)
|
_f := f.(*Fetcher)
|
||||||
|
_f.redirectLock.Lock()
|
||||||
|
redirectURL := _f.redirectURL
|
||||||
|
_f.redirectLock.Unlock()
|
||||||
return &fetcherData{
|
return &fetcherData{
|
||||||
Connections: _f.connections,
|
Connections: _f.connections,
|
||||||
|
RedirectURL: redirectURL,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +92,10 @@ func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v
|
|||||||
if len(fd.Connections) > 0 {
|
if len(fd.Connections) > 0 {
|
||||||
fetcher.connections = fd.Connections
|
fetcher.connections = fd.Connections
|
||||||
}
|
}
|
||||||
|
// Restore redirect URL for resume
|
||||||
|
if fd.RedirectURL != "" {
|
||||||
|
fetcher.redirectURL = fd.RedirectURL
|
||||||
|
}
|
||||||
return fetcher
|
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) {
|
func TestFetcherManager_ParseName(t *testing.T) {
|
||||||
type args struct {
|
type args struct {
|
||||||
u string
|
u string
|
||||||
@@ -1046,3 +1208,349 @@ func buildConfigFetcher(cfg config) fetcher.Fetcher {
|
|||||||
fetcher.Setup(newController)
|
fetcher.Setup(newController)
|
||||||
return fetcher
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// buildRequest creates an HTTP request using the redirect URL if available.
|
||||||
// HTTP Request/Client Building
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
func (f *Fetcher) buildRequest(ctx context.Context, req *base.Request) (httpReq *http.Request, err error) {
|
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
|
var reqUrl string
|
||||||
f.redirectLock.Lock()
|
f.redirectLock.Lock()
|
||||||
if f.redirectURL != "" {
|
if useRedirect && f.redirectURL != "" {
|
||||||
reqUrl = f.redirectURL
|
reqUrl = f.redirectURL
|
||||||
} else {
|
} else {
|
||||||
reqUrl = req.URL
|
reqUrl = req.URL
|
||||||
@@ -121,6 +130,65 @@ func (f *Fetcher) buildRequest(ctx context.Context, req *base.Request) (httpReq
|
|||||||
return httpReq, nil
|
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.
|
// buildClient creates an HTTP client with the default connection timeout.
|
||||||
// Used for resolve phase where we don't have connection time data yet.
|
// Used for resolve phase where we don't have connection time data yet.
|
||||||
func (f *Fetcher) buildClient() *http.Client {
|
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
|
// StartTestSlowStartServer creates a server with configurable delay per request
|
||||||
// This allows testing slow-start connection expansion to reach max connections
|
// This allows testing slow-start connection expansion to reach max connections
|
||||||
func StartTestSlowStartServer(delayPerByte time.Duration) net.Listener {
|
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:
|
// 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
|
// 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 {
|
func StartTestRangeBugServer() net.Listener {
|
||||||
@@ -739,6 +851,89 @@ func StartSocks5Server(usr, pwd string) net.Listener {
|
|||||||
return 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 {
|
func AssertResourceEqual(want, got *base.Resource) bool {
|
||||||
// Ignore ctime
|
// Ignore ctime
|
||||||
if got != nil && len(got.Files) > 0 {
|
if got != nil && len(got.Files) > 0 {
|
||||||
|
|||||||
+15
-2
@@ -78,7 +78,7 @@ func (r *Resource) Validate() error {
|
|||||||
if r.Name == "" {
|
if r.Name == "" {
|
||||||
return fmt.Errorf("invalid resource 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")
|
return fmt.Errorf("invalid resource files")
|
||||||
}
|
}
|
||||||
for _, file := range r.Files {
|
for _, file := range r.Files {
|
||||||
@@ -124,7 +124,7 @@ func (o *Options) InitSelectFiles(fileSize int) {
|
|||||||
// if selectFiles is empty, select all files
|
// if selectFiles is empty, select all files
|
||||||
if len(o.SelectFiles) == 0 {
|
if len(o.SelectFiles) == 0 {
|
||||||
o.SelectFiles = make([]int, fileSize)
|
o.SelectFiles = make([]int, fileSize)
|
||||||
for i := 0; i < fileSize; i++ {
|
for i := range fileSize {
|
||||||
o.SelectFiles[i] = i
|
o.SelectFiles[i] = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,6 +184,7 @@ type DownloaderStoreConfig struct {
|
|||||||
Extra map[string]any `json:"extra"`
|
Extra map[string]any `json:"extra"`
|
||||||
Proxy *DownloaderProxyConfig `json:"proxy"`
|
Proxy *DownloaderProxyConfig `json:"proxy"`
|
||||||
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
|
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
|
AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration
|
||||||
Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration
|
Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration
|
||||||
AutoDeleteMissingFileTasks bool `json:"autoDeleteMissingFileTasks"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files
|
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 {
|
if cfg.Webhook == nil {
|
||||||
cfg.Webhook = &WebhookConfig{}
|
cfg.Webhook = &WebhookConfig{}
|
||||||
}
|
}
|
||||||
|
if cfg.Script == nil {
|
||||||
|
cfg.Script = &ScriptConfig{}
|
||||||
|
}
|
||||||
if cfg.AutoTorrent == nil {
|
if cfg.AutoTorrent == nil {
|
||||||
cfg.AutoTorrent = &AutoTorrentConfig{
|
cfg.AutoTorrent = &AutoTorrentConfig{
|
||||||
Enable: false,
|
Enable: false,
|
||||||
@@ -239,6 +243,9 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
|
|||||||
if cfg.Webhook == nil {
|
if cfg.Webhook == nil {
|
||||||
cfg.Webhook = beforeCfg.Webhook
|
cfg.Webhook = beforeCfg.Webhook
|
||||||
}
|
}
|
||||||
|
if cfg.Script == nil {
|
||||||
|
cfg.Script = beforeCfg.Script
|
||||||
|
}
|
||||||
if cfg.AutoTorrent == nil {
|
if cfg.AutoTorrent == nil {
|
||||||
cfg.AutoTorrent = beforeCfg.AutoTorrent
|
cfg.AutoTorrent = beforeCfg.AutoTorrent
|
||||||
}
|
}
|
||||||
@@ -254,6 +261,12 @@ type WebhookConfig struct {
|
|||||||
URLs []string `json:"urls"` // URLs is the list of webhook URLs
|
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
|
// AutoTorrentConfig is the auto torrent task creation configuration
|
||||||
type AutoTorrentConfig struct {
|
type AutoTorrentConfig struct {
|
||||||
Enable bool `json:"enable"` // Enable enables automatic BT task creation when downloading .torrent files
|
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{},
|
ProtocolConfig: map[string]any{},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
Proxy: &DownloaderProxyConfig{},
|
||||||
Webhook: &WebhookConfig{},
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
AutoTorrent: &AutoTorrentConfig{
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
Enable: false,
|
Enable: false,
|
||||||
DeleteAfterDownload: false,
|
DeleteAfterDownload: false,
|
||||||
@@ -39,6 +40,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
ProtocolConfig: map[string]any{},
|
ProtocolConfig: map[string]any{},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
Proxy: &DownloaderProxyConfig{},
|
||||||
Webhook: &WebhookConfig{},
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
AutoTorrent: &AutoTorrentConfig{
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
Enable: false,
|
Enable: false,
|
||||||
DeleteAfterDownload: false,
|
DeleteAfterDownload: false,
|
||||||
@@ -63,6 +65,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
Proxy: &DownloaderProxyConfig{},
|
||||||
Webhook: &WebhookConfig{},
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
AutoTorrent: &AutoTorrentConfig{
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
Enable: false,
|
Enable: false,
|
||||||
DeleteAfterDownload: false,
|
DeleteAfterDownload: false,
|
||||||
@@ -87,6 +90,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
Enable: true,
|
Enable: true,
|
||||||
},
|
},
|
||||||
Webhook: &WebhookConfig{},
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
AutoTorrent: &AutoTorrentConfig{
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
Enable: false,
|
Enable: false,
|
||||||
DeleteAfterDownload: false,
|
DeleteAfterDownload: false,
|
||||||
@@ -110,6 +114,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
ProtocolConfig: map[string]any{},
|
ProtocolConfig: map[string]any{},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
Proxy: &DownloaderProxyConfig{},
|
||||||
Webhook: &WebhookConfig{},
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
AutoTorrent: &AutoTorrentConfig{
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
Enable: true,
|
Enable: true,
|
||||||
DeleteAfterDownload: true,
|
DeleteAfterDownload: true,
|
||||||
@@ -133,6 +138,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
ProtocolConfig: map[string]any{},
|
ProtocolConfig: map[string]any{},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
Proxy: &DownloaderProxyConfig{},
|
||||||
Webhook: &WebhookConfig{},
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
AutoTorrent: &AutoTorrentConfig{
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
Enable: false,
|
Enable: false,
|
||||||
DeleteAfterDownload: false,
|
DeleteAfterDownload: false,
|
||||||
@@ -154,6 +160,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
Extra: tt.fields.Extra,
|
Extra: tt.fields.Extra,
|
||||||
Proxy: tt.fields.Proxy,
|
Proxy: tt.fields.Proxy,
|
||||||
Webhook: tt.fields.Webhook,
|
Webhook: tt.fields.Webhook,
|
||||||
|
Script: tt.fields.Script,
|
||||||
AutoTorrent: tt.fields.AutoTorrent,
|
AutoTorrent: tt.fields.AutoTorrent,
|
||||||
Archive: tt.fields.Archive,
|
Archive: tt.fields.Archive,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -480,6 +480,47 @@ func (d *Downloader) Create(rrId string) (taskId string, err error) {
|
|||||||
return d.doCreate(fetcher, nil)
|
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) {
|
func (d *Downloader) Pause(filter *TaskFilter) (err error) {
|
||||||
if filter == nil || filter.IsEmpty() {
|
if filter == nil || filter.IsEmpty() {
|
||||||
return d.pauseAll()
|
return d.pauseAll()
|
||||||
@@ -954,6 +995,7 @@ func (d *Downloader) watch(task *Task) {
|
|||||||
d.notifyRunning()
|
d.notifyRunning()
|
||||||
d.triggerOnDone(task)
|
d.triggerOnDone(task)
|
||||||
d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||||
|
d.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||||
|
|
||||||
if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {
|
if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {
|
||||||
downloadFilePath := task.Meta.SingleFilepath()
|
downloadFilePath := task.Meta.SingleFilepath()
|
||||||
|
|||||||
@@ -2648,3 +2648,87 @@ func TestDownloader_AutoTorrentDisabled(t *testing.T) {
|
|||||||
t.Error("Torrent file should have been downloaded")
|
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"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/mholt/archives"
|
"github.com/mholt/archives"
|
||||||
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
)
|
)
|
||||||
|
|
||||||
// supportedArchiveExtensions contains file extensions supported by mholt/archives library
|
// supportedArchiveExtensions contains file extensions supported by mholt/archives library
|
||||||
@@ -70,6 +71,16 @@ type ArchivePartInfo struct {
|
|||||||
// ExtractProgressCallback is called to report extraction progress
|
// ExtractProgressCallback is called to report extraction progress
|
||||||
type ExtractProgressCallback func(extractedFiles int, totalFiles int, progress int)
|
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
|
// isArchiveFile checks if a file is a supported archive format
|
||||||
func isArchiveFile(filename string) bool {
|
func isArchiveFile(filename string) bool {
|
||||||
lowerName := strings.ToLower(filename)
|
lowerName := strings.ToLower(filename)
|
||||||
@@ -109,7 +120,8 @@ func openArchive(archivePath string, password string) (*archiveInfo, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle password-protected archives
|
// Configure format-specific settings
|
||||||
|
// Handle password-protected archives and character encoding
|
||||||
if password != "" {
|
if password != "" {
|
||||||
if rar, ok := format.(archives.Rar); ok {
|
if rar, ok := format.(archives.Rar); ok {
|
||||||
rar.Password = password
|
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{
|
return &archiveInfo{
|
||||||
file: file,
|
file: file,
|
||||||
stat: stat,
|
stat: stat,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIsArchiveFile(t *testing.T) {
|
func TestIsArchiveFile(t *testing.T) {
|
||||||
@@ -243,6 +245,69 @@ func createTestZipWithMultipleFiles(path string, numFiles int) error {
|
|||||||
return w.Close()
|
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) {
|
func TestOpenArchive_NonExistentFile(t *testing.T) {
|
||||||
_, err := openArchive("/nonexistent/path/file.zip", "")
|
_, err := openArchive("/nonexistent/path/file.zip", "")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -2382,3 +2447,47 @@ func TestExtractZipMultiPart_Progress(t *testing.T) {
|
|||||||
t.Error("Expected progress callbacks")
|
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
|
// First pass: count files for progress
|
||||||
totalFiles := 0
|
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 {
|
err = zip.Extract(context.Background(), io.NewSectionReader(multiReader, 0, totalSize), func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||||
if !fileInfo.IsDir() {
|
if !fileInfo.IsDir() {
|
||||||
totalFiles++
|
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) {
|
func PauseTask(w http.ResponseWriter, r *http.Request) {
|
||||||
filter, errResult := parseIdFilter(r)
|
filter, errResult := parseIdFilter(r)
|
||||||
if errResult != nil {
|
if errResult != nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type ResolveTask struct {
|
|||||||
Req *base.Request `json:"req"`
|
Req *base.Request `json:"req"`
|
||||||
Opts *base.Options `json:"opts"`
|
Opts *base.Options `json:"opts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateTask struct {
|
type CreateTask struct {
|
||||||
Rid string `json:"rid"`
|
Rid string `json:"rid"`
|
||||||
|
|
||||||
|
|||||||
+12
-10
@@ -8,12 +8,6 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"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"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net"
|
"net"
|
||||||
@@ -24,6 +18,13 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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 (
|
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/resolve").HandlerFunc(Resolve)
|
||||||
r.Methods(http.MethodPost).Path("/api/v1/tasks").HandlerFunc(CreateTask)
|
r.Methods(http.MethodPost).Path("/api/v1/tasks").HandlerFunc(CreateTask)
|
||||||
r.Methods(http.MethodPost).Path("/api/v1/tasks/batch").HandlerFunc(CreateTaskBatch)
|
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/{id}/pause").HandlerFunc(PauseTask)
|
||||||
r.Methods(http.MethodPut).Path("/api/v1/tasks/pause").HandlerFunc(PauseTasks)
|
r.Methods(http.MethodPut).Path("/api/v1/tasks/pause").HandlerFunc(PauseTasks)
|
||||||
r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/continue").HandlerFunc(ContinueTask)
|
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)
|
r.Path("/api/v1/proxy").HandlerFunc(DoProxy)
|
||||||
|
|
||||||
enableApiToken := startCfg.ApiToken != ""
|
enableApiToken := startCfg.ApiToken != ""
|
||||||
enableBasicAuth := startCfg.WebEnable && startCfg.WebAuth != nil
|
enableWebAuth := startCfg.WebEnable && startCfg.WebAuth != nil
|
||||||
if startCfg.WebEnable {
|
if startCfg.WebEnable {
|
||||||
if enableBasicAuth {
|
if enableWebAuth {
|
||||||
r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
var loginReq model.WebAuth
|
var loginReq model.WebAuth
|
||||||
if ReadJson(r, w, &loginReq) {
|
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("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem)))
|
||||||
r.PathPrefix("/").Handler(gzipMiddleware(http.FileServer(newEmbedCacheFileSystem(http.FS(startCfg.WebFS)))))
|
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) {
|
writeUnauthorized := func(w http.ResponseWriter, r *http.Request) {
|
||||||
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
|
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" {
|
if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/web/login" {
|
||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
return
|
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) {
|
func TestPauseAllAndContinueALLTasks(t *testing.T) {
|
||||||
doTest(func() {
|
doTest(func() {
|
||||||
cfg, err := Downloader.GetConfig()
|
cfg, err := Downloader.GetConfig()
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ android {
|
|||||||
ndkVersion flutter.ndkVersion
|
ndkVersion flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
coreLibraryDesugaringEnabled true
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
@@ -83,4 +84,5 @@ flutter {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(name: 'libgopeed', ext: 'aar')
|
implementation(name: 'libgopeed', ext: 'aar')
|
||||||
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||||
}
|
}
|
||||||
@@ -141,6 +141,11 @@ Future<List<String>> createTaskBatch(CreateTaskBatch createTaskBatch) async {
|
|||||||
(data) => (data as List).map((e) => e as String).toList());
|
(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 {
|
Future<List<Task>> getTasks(List<Status> statuses) async {
|
||||||
return _parse<List<Task>>(
|
return _parse<List<Task>>(
|
||||||
() => _client.dio.get(
|
() => _client.dio.get(
|
||||||
@@ -195,10 +200,10 @@ Future<void> putConfig(DownloaderConfig config) async {
|
|||||||
return _parse(() => _client.dio.put("api/v1/config", data: config), null);
|
return _parse(() => _client.dio.put("api/v1/config", data: config), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> installExtension(InstallExtension installExtension) async {
|
Future<String> installExtension(InstallExtension installExtension) async {
|
||||||
return _parse(
|
return _parse<String>(
|
||||||
() => _client.dio.post("api/v1/extensions", data: installExtension),
|
() => _client.dio.post("api/v1/extensions", data: installExtension),
|
||||||
null);
|
(data) => data as String);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Extension>> getExtensions() async {
|
Future<List<Extension>> getExtensions() async {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import 'api.dart';
|
||||||
|
import 'model/store_extension.dart';
|
||||||
|
|
||||||
|
class GopeedSiteApi {
|
||||||
|
GopeedSiteApi._();
|
||||||
|
|
||||||
|
static final instance = GopeedSiteApi._();
|
||||||
|
|
||||||
|
static const _host = 'gopeed.com';
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getRelease() async {
|
||||||
|
final json = await _getJson('/api/release');
|
||||||
|
return json as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StoreExtensionPage> getExtensions({
|
||||||
|
int page = 1,
|
||||||
|
int limit = 20,
|
||||||
|
StoreExtensionSort sort = StoreExtensionSort.stars,
|
||||||
|
StoreSortOrder order = StoreSortOrder.desc,
|
||||||
|
String? query,
|
||||||
|
}) async {
|
||||||
|
final json = await _getJson('/api/extensions', queryParameters: {
|
||||||
|
'page': page.toString(),
|
||||||
|
'limit': limit.clamp(1, 100).toString(),
|
||||||
|
'sort': sort.name,
|
||||||
|
'order': order.name,
|
||||||
|
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
|
||||||
|
});
|
||||||
|
return StoreExtensionPage.fromJson(json as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reportExtensionInstall(String id) async {
|
||||||
|
final uri = Uri.https(_host, '/api/extensions/install');
|
||||||
|
await proxyRequest(
|
||||||
|
uri.toString(),
|
||||||
|
data: {'id': id},
|
||||||
|
options: Options(method: 'POST', contentType: Headers.jsonContentType),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _getJson(String path,
|
||||||
|
{Map<String, String>? queryParameters}) async {
|
||||||
|
final uri = Uri.https(_host, path, queryParameters);
|
||||||
|
final Response<String> response = await proxyRequest(uri.toString());
|
||||||
|
if (response.data == null || response.data!.isEmpty) {
|
||||||
|
throw Exception('Empty response from $uri');
|
||||||
|
}
|
||||||
|
return jsonDecode(response.data!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ class DownloaderConfig {
|
|||||||
ExtraConfig extra = ExtraConfig();
|
ExtraConfig extra = ExtraConfig();
|
||||||
ProxyConfig proxy = ProxyConfig();
|
ProxyConfig proxy = ProxyConfig();
|
||||||
WebhookConfig webhook = WebhookConfig();
|
WebhookConfig webhook = WebhookConfig();
|
||||||
|
ScriptConfig script = ScriptConfig();
|
||||||
AutoTorrentConfig autoTorrent = AutoTorrentConfig();
|
AutoTorrentConfig autoTorrent = AutoTorrentConfig();
|
||||||
ArchiveConfig archive = ArchiveConfig();
|
ArchiveConfig archive = ArchiveConfig();
|
||||||
bool autoDeleteMissingFileTasks;
|
bool autoDeleteMissingFileTasks;
|
||||||
@@ -88,6 +89,7 @@ class ExtraConfig {
|
|||||||
bool defaultBtClient;
|
bool defaultBtClient;
|
||||||
bool notifyWhenNewVersion;
|
bool notifyWhenNewVersion;
|
||||||
bool autoStartTasks;
|
bool autoStartTasks;
|
||||||
|
bool desktopNotification;
|
||||||
List<DownloadCategory> downloadCategories;
|
List<DownloadCategory> downloadCategories;
|
||||||
|
|
||||||
ExtraConfigBt bt = ExtraConfigBt();
|
ExtraConfigBt bt = ExtraConfigBt();
|
||||||
@@ -101,6 +103,7 @@ class ExtraConfig {
|
|||||||
this.defaultBtClient = true,
|
this.defaultBtClient = true,
|
||||||
this.notifyWhenNewVersion = true,
|
this.notifyWhenNewVersion = true,
|
||||||
this.autoStartTasks = false,
|
this.autoStartTasks = false,
|
||||||
|
this.desktopNotification = true,
|
||||||
this.downloadCategories = const [],
|
this.downloadCategories = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,6 +152,22 @@ class WebhookConfig {
|
|||||||
Map<String, dynamic> toJson() => _$WebhookConfigToJson(this);
|
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()
|
@JsonSerializable()
|
||||||
class ProxyConfig {
|
class ProxyConfig {
|
||||||
bool enable;
|
bool enable;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
|
|||||||
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
|
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
|
||||||
..webhook =
|
..webhook =
|
||||||
WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
|
WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
|
||||||
|
..script = ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
|
||||||
..autoTorrent = AutoTorrentConfig.fromJson(
|
..autoTorrent = AutoTorrentConfig.fromJson(
|
||||||
json['autoTorrent'] as Map<String, dynamic>?)
|
json['autoTorrent'] as Map<String, dynamic>?)
|
||||||
..archive =
|
..archive =
|
||||||
@@ -32,6 +33,7 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
|
|||||||
'extra': instance.extra.toJson(),
|
'extra': instance.extra.toJson(),
|
||||||
'proxy': instance.proxy.toJson(),
|
'proxy': instance.proxy.toJson(),
|
||||||
'webhook': instance.webhook.toJson(),
|
'webhook': instance.webhook.toJson(),
|
||||||
|
'script': instance.script.toJson(),
|
||||||
'autoTorrent': instance.autoTorrent.toJson(),
|
'autoTorrent': instance.autoTorrent.toJson(),
|
||||||
'archive': instance.archive.toJson(),
|
'archive': instance.archive.toJson(),
|
||||||
'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks,
|
'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks,
|
||||||
@@ -88,6 +90,7 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
|
|||||||
defaultBtClient: json['defaultBtClient'] as bool? ?? true,
|
defaultBtClient: json['defaultBtClient'] as bool? ?? true,
|
||||||
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
|
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
|
||||||
autoStartTasks: json['autoStartTasks'] as bool? ?? false,
|
autoStartTasks: json['autoStartTasks'] as bool? ?? false,
|
||||||
|
desktopNotification: json['desktopNotification'] as bool? ?? true,
|
||||||
downloadCategories: (json['downloadCategories'] as List<dynamic>?)
|
downloadCategories: (json['downloadCategories'] as List<dynamic>?)
|
||||||
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
|
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
@@ -106,6 +109,7 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
|
|||||||
'defaultBtClient': instance.defaultBtClient,
|
'defaultBtClient': instance.defaultBtClient,
|
||||||
'notifyWhenNewVersion': instance.notifyWhenNewVersion,
|
'notifyWhenNewVersion': instance.notifyWhenNewVersion,
|
||||||
'autoStartTasks': instance.autoStartTasks,
|
'autoStartTasks': instance.autoStartTasks,
|
||||||
|
'desktopNotification': instance.desktopNotification,
|
||||||
'downloadCategories':
|
'downloadCategories':
|
||||||
instance.downloadCategories.map((e) => e.toJson()).toList(),
|
instance.downloadCategories.map((e) => e.toJson()).toList(),
|
||||||
'bt': instance.bt.toJson(),
|
'bt': instance.bt.toJson(),
|
||||||
@@ -153,6 +157,19 @@ Map<String, dynamic> _$WebhookConfigToJson(WebhookConfig instance) =>
|
|||||||
'urls': instance.urls,
|
'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(
|
ProxyConfig _$ProxyConfigFromJson(Map<String, dynamic> json) => ProxyConfig(
|
||||||
enable: json['enable'] as bool? ?? false,
|
enable: json['enable'] as bool? ?? false,
|
||||||
system: json['system'] as bool? ?? false,
|
system: json['system'] as bool? ?? false,
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
class StoreExtensionPage {
|
||||||
|
final List<StoreExtension> data;
|
||||||
|
final StorePagination pagination;
|
||||||
|
|
||||||
|
StoreExtensionPage({required this.data, required this.pagination});
|
||||||
|
|
||||||
|
factory StoreExtensionPage.fromJson(Map<String, dynamic> json) {
|
||||||
|
return StoreExtensionPage(
|
||||||
|
data: (json['data'] as List<dynamic>? ?? [])
|
||||||
|
.map((e) => StoreExtension.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
pagination: StorePagination.fromJson(
|
||||||
|
json['pagination'] as Map<String, dynamic>? ?? {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StorePagination {
|
||||||
|
final int page;
|
||||||
|
final int limit;
|
||||||
|
final int total;
|
||||||
|
final int totalPages;
|
||||||
|
final bool hasNext;
|
||||||
|
final bool hasPrev;
|
||||||
|
|
||||||
|
StorePagination({
|
||||||
|
required this.page,
|
||||||
|
required this.limit,
|
||||||
|
required this.total,
|
||||||
|
required this.totalPages,
|
||||||
|
required this.hasNext,
|
||||||
|
required this.hasPrev,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StorePagination.fromJson(Map<String, dynamic> json) {
|
||||||
|
return StorePagination(
|
||||||
|
page: (json['page'] as num?)?.toInt() ?? 1,
|
||||||
|
limit: (json['limit'] as num?)?.toInt() ?? 20,
|
||||||
|
total: (json['total'] as num?)?.toInt() ?? 0,
|
||||||
|
totalPages: (json['totalPages'] as num?)?.toInt() ?? 1,
|
||||||
|
hasNext: json['hasNext'] as bool? ?? false,
|
||||||
|
hasPrev: json['hasPrev'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StoreExtension {
|
||||||
|
final String id;
|
||||||
|
final String repoFullName;
|
||||||
|
final String repoUrl;
|
||||||
|
final String? directory;
|
||||||
|
final String? commitSha;
|
||||||
|
final String name;
|
||||||
|
final String author;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final String? icon;
|
||||||
|
final String version;
|
||||||
|
final String? homepage;
|
||||||
|
final String? readme;
|
||||||
|
final int installCount;
|
||||||
|
final int stars;
|
||||||
|
final List<String> topics;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
|
StoreExtension({
|
||||||
|
required this.id,
|
||||||
|
required this.repoFullName,
|
||||||
|
required this.repoUrl,
|
||||||
|
this.directory,
|
||||||
|
this.commitSha,
|
||||||
|
required this.name,
|
||||||
|
required this.author,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
this.icon,
|
||||||
|
required this.version,
|
||||||
|
this.homepage,
|
||||||
|
this.readme,
|
||||||
|
required this.installCount,
|
||||||
|
required this.stars,
|
||||||
|
required this.topics,
|
||||||
|
this.createdAt,
|
||||||
|
this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StoreExtension.fromJson(Map<String, dynamic> json) {
|
||||||
|
return StoreExtension(
|
||||||
|
id: json['id'] as String? ?? '',
|
||||||
|
repoFullName: json['repoFullName'] as String? ?? '',
|
||||||
|
repoUrl: json['repoUrl'] as String? ?? '',
|
||||||
|
directory: json['directory'] as String?,
|
||||||
|
commitSha: json['commitSha'] as String?,
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
author: json['author'] as String? ?? '',
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
description: json['description'] as String? ?? '',
|
||||||
|
icon: json['icon'] as String?,
|
||||||
|
version: json['version'] as String? ?? '0.0.0',
|
||||||
|
homepage: json['homepage'] as String?,
|
||||||
|
readme: json['readme'] as String?,
|
||||||
|
installCount: (json['installCount'] as num?)?.toInt() ?? 0,
|
||||||
|
stars: (json['stars'] as num?)?.toInt() ?? 0,
|
||||||
|
topics: _parseTopics(json['topics']),
|
||||||
|
createdAt: _parseDate(json['createdAt']),
|
||||||
|
updatedAt: _parseDate(json['updatedAt']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _parseTopics(dynamic value) {
|
||||||
|
if (value is List) {
|
||||||
|
return value.map((e) => e.toString()).toList();
|
||||||
|
}
|
||||||
|
if (value is String && value.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final parsed = jsonDecode(value);
|
||||||
|
if (parsed is List) {
|
||||||
|
return parsed.map((e) => e.toString()).toList();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime? _parseDate(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is int) {
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(value);
|
||||||
|
}
|
||||||
|
if (value is String && value.isNotEmpty) {
|
||||||
|
return DateTime.tryParse(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StoreExtensionSort {
|
||||||
|
stars,
|
||||||
|
installs,
|
||||||
|
updated,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StoreSortOrder {
|
||||||
|
asc,
|
||||||
|
desc,
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import 'package:uri_to_file/uri_to_file.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
|
||||||
import '../../../../api/api.dart';
|
import '../../../../api/api.dart';
|
||||||
import '../../../../api/model/create_task.dart';
|
import '../../../../api/model/create_task.dart';
|
||||||
import '../../../../api/model/downloader_config.dart';
|
import '../../../../api/model/downloader_config.dart';
|
||||||
@@ -38,6 +39,7 @@ import '../../../../util/util.dart';
|
|||||||
import '../../../routes/app_pages.dart';
|
import '../../../routes/app_pages.dart';
|
||||||
import '../../../rpc/rpc.dart';
|
import '../../../rpc/rpc.dart';
|
||||||
import '../../redirect/views/redirect_view.dart';
|
import '../../redirect/views/redirect_view.dart';
|
||||||
|
import '../../../services/notification_service.dart';
|
||||||
|
|
||||||
const unixSocketPath = 'gopeed.sock';
|
const unixSocketPath = 'gopeed.sock';
|
||||||
|
|
||||||
@@ -59,14 +61,31 @@ final allTrackerSubscribeUrlCdns = {
|
|||||||
v: githubMirrorUrls(v, MirrorType.githubSource)
|
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 {
|
class AppController extends GetxController with WindowListener, TrayListener {
|
||||||
static StartConfig? _defaultStartConfig;
|
static StartConfig? _defaultStartConfig;
|
||||||
|
|
||||||
|
/// Command line --hidden flag passed from main.dart
|
||||||
|
final bool hiddenFromArgs;
|
||||||
|
|
||||||
|
AppController({this.hiddenFromArgs = false});
|
||||||
|
|
||||||
final autoStartup = false.obs;
|
final autoStartup = false.obs;
|
||||||
final startConfig = StartConfig().obs;
|
final startConfig = StartConfig().obs;
|
||||||
final runningPort = 0.obs;
|
final runningPort = 0.obs;
|
||||||
final downloaderConfig = DownloaderConfig().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;
|
late AppLinks _appLinks;
|
||||||
StreamSubscription<Uri>? _linkSubscription;
|
StreamSubscription<Uri>? _linkSubscription;
|
||||||
|
|
||||||
@@ -79,6 +98,10 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
|||||||
|
|
||||||
_initWindows().onError((error, stackTrace) =>
|
_initWindows().onError((error, stackTrace) =>
|
||||||
logger.w("initWindows error", error, stackTrace));
|
logger.w("initWindows error", error, stackTrace));
|
||||||
|
|
||||||
|
if (Util.isDesktop()) {
|
||||||
|
Get.put(NotificationService());
|
||||||
|
}
|
||||||
|
|
||||||
_initTray().onError(
|
_initTray().onError(
|
||||||
(error, stackTrace) => logger.w("initTray error", error, stackTrace));
|
(error, stackTrace) => logger.w("initTray error", error, stackTrace));
|
||||||
@@ -156,24 +179,44 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
|||||||
|
|
||||||
Future<void> _initDeepLinks() async {
|
Future<void> _initDeepLinks() async {
|
||||||
if (Util.isWeb()) {
|
if (Util.isWeb()) {
|
||||||
|
// For web, just show window
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle deep link
|
// Handle deep link
|
||||||
() async {
|
_appLinks = AppLinks();
|
||||||
_appLinks = AppLinks();
|
|
||||||
|
|
||||||
// Handle link when app is in warm state (front or background)
|
// Handle link when app is in warm state (front or background)
|
||||||
_linkSubscription = _appLinks.uriLinkStream.listen((uri) async {
|
_linkSubscription = _appLinks.uriLinkStream.listen((uri) async {
|
||||||
await _handleDeepLink(uri);
|
await _handleDeepLink(uri);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check initial link if app was in cold state (terminated)
|
// Get initial link for cold start - this works after runApp()
|
||||||
final uri = await _appLinks.getInitialLink();
|
Uri? initialLink;
|
||||||
if (uri != null) {
|
try {
|
||||||
await _handleDeepLink(uri);
|
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
|
// Handle shared media, e.g. shared link from browser
|
||||||
if (Util.isMobile()) {
|
if (Util.isMobile()) {
|
||||||
@@ -295,7 +338,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
|||||||
final silent = jsonMeta['silent'] as bool? ?? false;
|
final silent = jsonMeta['silent'] as bool? ?? false;
|
||||||
final params = await ctx.readText();
|
final params = await ctx.readText();
|
||||||
final createTaskParams = CreateTask.fromJson(_decodeParams(params));
|
final createTaskParams = CreateTask.fromJson(_decodeParams(params));
|
||||||
if (!silent) {
|
if (!silent || pendingUpdateTask.value != null) {
|
||||||
await windowManager.show();
|
await windowManager.show();
|
||||||
_handleToCreate0(createTaskParams);
|
_handleToCreate0(createTaskParams);
|
||||||
} else {
|
} else {
|
||||||
@@ -638,7 +681,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
|||||||
launchAtStartup.setup(
|
launchAtStartup.setup(
|
||||||
appName: packageInfo.appName,
|
appName: packageInfo.appName,
|
||||||
appPath: Platform.resolvedExecutable,
|
appPath: Platform.resolvedExecutable,
|
||||||
args: ['--${Args.flagHidden}']);
|
args: ['--${StartupArgs.flagHidden}']);
|
||||||
autoStartup.value = await launchAtStartup.isEnabled();
|
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
|
// Set the title bar to use the actual brightness of the UI
|
||||||
windowManager.setBrightness(brightness);
|
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();
|
: _urlController.text.trim();
|
||||||
|
|
||||||
final urls = Util.textToLines(submitUrl);
|
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
|
// Add url to the history
|
||||||
if (!isWebFileChosen) {
|
if (!isWebFileChosen) {
|
||||||
for (final url in urls) {
|
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() {
|
RequestProxy? parseProxy() {
|
||||||
if (controller.proxyConfig.value?.mode == RequestProxyMode.custom) {
|
if (controller.proxyConfig.value?.mode == RequestProxyMode.custom) {
|
||||||
return RequestProxy()
|
return RequestProxy()
|
||||||
|
|||||||
@@ -1,38 +1,278 @@
|
|||||||
import 'package:get/get.dart';
|
import 'dart:async';
|
||||||
import 'package:gopeed/api/api.dart';
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
import '../../../../api/api.dart';
|
||||||
|
import '../../../../api/gopeed_site_api.dart';
|
||||||
import '../../../../api/model/extension.dart';
|
import '../../../../api/model/extension.dart';
|
||||||
|
import '../../../../api/model/install_extension.dart';
|
||||||
|
import '../../../../api/model/store_extension.dart';
|
||||||
|
import '../../../../api/model/switch_extension.dart';
|
||||||
|
|
||||||
|
enum ExtensionListFilter {
|
||||||
|
market,
|
||||||
|
installed,
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExtensionListItem {
|
||||||
|
final Extension? installed;
|
||||||
|
final StoreExtension? store;
|
||||||
|
|
||||||
|
const ExtensionListItem({this.installed, this.store});
|
||||||
|
|
||||||
|
bool get isInstalled => installed != null;
|
||||||
|
|
||||||
|
String get id => installed?.identity ?? store!.id;
|
||||||
|
|
||||||
|
String get title => installed?.title ?? store?.title ?? '';
|
||||||
|
|
||||||
|
String get author => installed?.author ?? store?.author ?? '';
|
||||||
|
|
||||||
|
String get description => installed?.description ?? store?.description ?? '';
|
||||||
|
|
||||||
|
String get version => installed?.version ?? store?.version ?? '0.0.0';
|
||||||
|
|
||||||
|
String? get homepage {
|
||||||
|
final installedHomepage = installed?.homepage;
|
||||||
|
if (installedHomepage != null && installedHomepage.isNotEmpty) {
|
||||||
|
return installedHomepage;
|
||||||
|
}
|
||||||
|
return store?.homepage;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get repoUrl {
|
||||||
|
final installedRepo = installed?.repository?.url;
|
||||||
|
if (installedRepo != null && installedRepo.isNotEmpty) {
|
||||||
|
return installedRepo;
|
||||||
|
}
|
||||||
|
return store?.repoUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
int get stars => store?.stars ?? 0;
|
||||||
|
|
||||||
|
int get installCount => store?.installCount ?? 0;
|
||||||
|
|
||||||
|
String? get icon => store?.icon;
|
||||||
|
}
|
||||||
|
|
||||||
class ExtensionController extends GetxController {
|
class ExtensionController extends GetxController {
|
||||||
final extensions = <Extension>[].obs;
|
static const manualInstallBusyKey = '__manual_install__';
|
||||||
|
|
||||||
|
final installedExtensions = <Extension>[].obs;
|
||||||
final updateFlags = <String, String>{}.obs;
|
final updateFlags = <String, String>{}.obs;
|
||||||
|
|
||||||
|
final storeExtensions = <StoreExtension>[].obs;
|
||||||
|
final storePagination = Rxn<StorePagination>();
|
||||||
|
final storeQuery = ''.obs;
|
||||||
|
final storeSort = StoreExtensionSort.stars.obs;
|
||||||
|
|
||||||
|
final listFilter = ExtensionListFilter.market.obs;
|
||||||
|
|
||||||
|
final loadingInstalled = false.obs;
|
||||||
|
final loadingStore = false.obs;
|
||||||
|
final loadingMoreStore = false.obs;
|
||||||
|
final busyExtensionIds = <String>{}.obs;
|
||||||
|
final showInstallTools = false.obs;
|
||||||
|
|
||||||
final devMode = false.obs;
|
final devMode = false.obs;
|
||||||
var _devModeCount = 0;
|
var _devModeCount = 0;
|
||||||
|
|
||||||
/// Flag to prevent handling pending install multiple times
|
|
||||||
bool pendingInstallHandled = false;
|
bool pendingInstallHandled = false;
|
||||||
|
|
||||||
|
UnmodifiableMapView<String, Extension> get installedMap =>
|
||||||
|
UnmodifiableMapView(
|
||||||
|
{for (final ext in installedExtensions) ext.identity: ext});
|
||||||
|
|
||||||
|
UnmodifiableMapView<String, StoreExtension> get storeMap =>
|
||||||
|
UnmodifiableMapView({for (final ext in storeExtensions) ext.id: ext});
|
||||||
|
|
||||||
|
List<ExtensionListItem> get displayItems {
|
||||||
|
if (listFilter.value == ExtensionListFilter.installed) {
|
||||||
|
return installedExtensions
|
||||||
|
.map((ext) =>
|
||||||
|
ExtensionListItem(installed: ext, store: storeMap[ext.identity]))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return storeExtensions
|
||||||
|
.map((ext) =>
|
||||||
|
ExtensionListItem(installed: installedMap[ext.id], store: ext))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() async {
|
Future<void> onInit() async {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
await load();
|
await loadInitialData();
|
||||||
checkUpdate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> load() async {
|
Future<void> loadInitialData() async {
|
||||||
extensions.value = await getExtensions();
|
await Future.wait([loadInstalled(refreshUpdates: true), refreshStore()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> checkUpdate() async {
|
Future<void> loadInstalled({bool refreshUpdates = false}) async {
|
||||||
for (final ext in extensions) {
|
loadingInstalled.value = true;
|
||||||
final resp = await upgradeCheckExtension(ext.identity);
|
try {
|
||||||
if (resp.newVersion.isNotEmpty) {
|
installedExtensions.value = await getExtensions();
|
||||||
updateFlags[ext.identity] = resp.newVersion;
|
if (refreshUpdates) {
|
||||||
|
await checkUpdate();
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
loadingInstalled.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to open dev mode when install button is clicked 5 times in 2 seconds
|
Future<void> checkUpdate() async {
|
||||||
|
final nextFlags = <String, String>{};
|
||||||
|
for (final ext in installedExtensions) {
|
||||||
|
try {
|
||||||
|
final resp = await upgradeCheckExtension(ext.identity);
|
||||||
|
if (resp.newVersion.isNotEmpty) {
|
||||||
|
nextFlags[ext.identity] = resp.newVersion;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore single extension check failures to avoid breaking the whole list.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateFlags.assignAll(nextFlags);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshStore() async {
|
||||||
|
loadingStore.value = true;
|
||||||
|
try {
|
||||||
|
final page = await GopeedSiteApi.instance.getExtensions(
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
sort: storeSort.value,
|
||||||
|
query: storeQuery.value,
|
||||||
|
);
|
||||||
|
storeExtensions.assignAll(page.data);
|
||||||
|
storePagination.value = page.pagination;
|
||||||
|
} finally {
|
||||||
|
loadingStore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadMoreStore() async {
|
||||||
|
final pagination = storePagination.value;
|
||||||
|
if (pagination == null || !pagination.hasNext || loadingMoreStore.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadingMoreStore.value = true;
|
||||||
|
try {
|
||||||
|
final page = await GopeedSiteApi.instance.getExtensions(
|
||||||
|
page: pagination.page + 1,
|
||||||
|
limit: pagination.limit,
|
||||||
|
sort: storeSort.value,
|
||||||
|
query: storeQuery.value,
|
||||||
|
);
|
||||||
|
storeExtensions.addAll(page.data);
|
||||||
|
storePagination.value = page.pagination;
|
||||||
|
} finally {
|
||||||
|
loadingMoreStore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> searchStore(String query) async {
|
||||||
|
storeQuery.value = query.trim();
|
||||||
|
await refreshStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> changeSort(StoreExtensionSort sort) async {
|
||||||
|
if (storeSort.value == sort) {
|
||||||
|
await refreshStore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
storeSort.value = sort;
|
||||||
|
await refreshStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
void changeFilter(ExtensionListFilter filter) {
|
||||||
|
listFilter.value = filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleInstallTools() {
|
||||||
|
showInstallTools.value = !showInstallTools.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Extension? findInstalled(StoreExtension extension) {
|
||||||
|
return installedMap[extension.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canUpdateFromStore(StoreExtension extension) {
|
||||||
|
final installed = findInstalled(extension);
|
||||||
|
if (installed == null) return false;
|
||||||
|
return _compareVersion(extension.version, installed.version) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canUpdateItem(ExtensionListItem item) {
|
||||||
|
if (item.installed == null) return false;
|
||||||
|
if (listFilter.value == ExtensionListFilter.market && item.store != null) {
|
||||||
|
return canUpdateFromStore(item.store!);
|
||||||
|
}
|
||||||
|
return updateFlags.containsKey(item.installed!.identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> installFromStore(StoreExtension extension) async {
|
||||||
|
await _runBusy(extension.id, () async {
|
||||||
|
final installUrl = (extension.directory ?? '').trim().isEmpty
|
||||||
|
? extension.repoUrl
|
||||||
|
: '${extension.repoUrl}#${extension.directory!.trim()}';
|
||||||
|
|
||||||
|
final installedId =
|
||||||
|
await installExtension(InstallExtension(url: installUrl));
|
||||||
|
final statsId = installedId.isNotEmpty ? installedId : extension.id;
|
||||||
|
await loadInstalled(refreshUpdates: false);
|
||||||
|
_backgroundCheckUpdate();
|
||||||
|
_bumpStoreInstallCount(statsId);
|
||||||
|
_reportInstallSafe(statsId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> installFromUrl(String url,
|
||||||
|
{bool devInstall = false, String? statsId}) async {
|
||||||
|
await _runBusy(manualInstallBusyKey, () async {
|
||||||
|
final installedId = await installExtension(
|
||||||
|
InstallExtension(devMode: devInstall, url: url));
|
||||||
|
await loadInstalled(refreshUpdates: false);
|
||||||
|
_backgroundCheckUpdate();
|
||||||
|
if (installedId.isNotEmpty) {
|
||||||
|
_bumpStoreInstallCount(installedId);
|
||||||
|
_reportInstallSafe(installedId);
|
||||||
|
} else if (statsId != null && statsId.isNotEmpty) {
|
||||||
|
_bumpStoreInstallCount(statsId);
|
||||||
|
_reportInstallSafe(statsId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleExtension(Extension extension, bool enabled) async {
|
||||||
|
await _runBusy(extension.identity, () async {
|
||||||
|
await switchExtension(
|
||||||
|
extension.identity, SwitchExtension(status: enabled));
|
||||||
|
await loadInstalled(refreshUpdates: false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeExtension(Extension extension) async {
|
||||||
|
await _runBusy(extension.identity, () async {
|
||||||
|
await deleteExtension(extension.identity);
|
||||||
|
await loadInstalled(refreshUpdates: false);
|
||||||
|
updateFlags.remove(extension.identity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> upgradeExtension(Extension extension) async {
|
||||||
|
await _runBusy(extension.identity, () async {
|
||||||
|
await updateExtension(extension.identity);
|
||||||
|
await loadInstalled(refreshUpdates: false);
|
||||||
|
_backgroundCheckUpdate();
|
||||||
|
_bumpStoreInstallCount(extension.identity);
|
||||||
|
_reportInstallSafe(extension.identity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void tryOpenDevMode() {
|
void tryOpenDevMode() {
|
||||||
if (_devModeCount == 0) {
|
if (_devModeCount == 0) {
|
||||||
Future.delayed(const Duration(seconds: 2), () {
|
Future.delayed(const Duration(seconds: 2), () {
|
||||||
@@ -46,4 +286,76 @@ class ExtensionController extends GetxController {
|
|||||||
devMode.value = true;
|
devMode.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int _compareVersion(String a, String b) {
|
||||||
|
final aNums = _toVersionNumbers(a);
|
||||||
|
final bNums = _toVersionNumbers(b);
|
||||||
|
final maxLen = aNums.length > bNums.length ? aNums.length : bNums.length;
|
||||||
|
|
||||||
|
for (var i = 0; i < maxLen; i++) {
|
||||||
|
final left = i < aNums.length ? aNums[i] : 0;
|
||||||
|
final right = i < bNums.length ? bNums[i] : 0;
|
||||||
|
if (left > right) return 1;
|
||||||
|
if (left < right) return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<int> _toVersionNumbers(String version) {
|
||||||
|
return version
|
||||||
|
.split(RegExp(r'[^0-9]+'))
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.map((part) => int.tryParse(part) ?? 0)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runBusy(String id, Future<void> Function() action) async {
|
||||||
|
if (busyExtensionIds.contains(id)) return;
|
||||||
|
busyExtensionIds.add(id);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} finally {
|
||||||
|
busyExtensionIds.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _backgroundCheckUpdate() {
|
||||||
|
unawaited(checkUpdate());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reportInstallSafe(String id) {
|
||||||
|
unawaited(() async {
|
||||||
|
try {
|
||||||
|
await GopeedSiteApi.instance.reportExtensionInstall(id);
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore stats reporting failures.
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _bumpStoreInstallCount(String id) {
|
||||||
|
final index = storeExtensions.indexWhere((e) => e.id == id);
|
||||||
|
if (index < 0) return;
|
||||||
|
final ext = storeExtensions[index];
|
||||||
|
storeExtensions[index] = StoreExtension(
|
||||||
|
id: ext.id,
|
||||||
|
repoFullName: ext.repoFullName,
|
||||||
|
repoUrl: ext.repoUrl,
|
||||||
|
directory: ext.directory,
|
||||||
|
commitSha: ext.commitSha,
|
||||||
|
name: ext.name,
|
||||||
|
author: ext.author,
|
||||||
|
title: ext.title,
|
||||||
|
description: ext.description,
|
||||||
|
icon: ext.icon,
|
||||||
|
version: ext.version,
|
||||||
|
homepage: ext.homepage,
|
||||||
|
readme: ext.readme,
|
||||||
|
installCount: ext.installCount + 1,
|
||||||
|
stars: ext.stars,
|
||||||
|
topics: ext.topics,
|
||||||
|
createdAt: ext.createdAt,
|
||||||
|
updatedAt: ext.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../../../../api/api.dart';
|
||||||
|
import '../../../../database/database.dart';
|
||||||
|
import '../../../../util/util.dart';
|
||||||
|
import '../controllers/extension_controller.dart';
|
||||||
|
|
||||||
|
class ExtensionCard extends StatelessWidget {
|
||||||
|
const ExtensionCard({
|
||||||
|
super.key,
|
||||||
|
required this.item,
|
||||||
|
required this.busy,
|
||||||
|
required this.canUpdate,
|
||||||
|
this.onTap,
|
||||||
|
this.onToggle,
|
||||||
|
this.onOpenSetting,
|
||||||
|
this.onUpdate,
|
||||||
|
this.onDelete,
|
||||||
|
this.onInstall,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ExtensionListItem item;
|
||||||
|
final bool busy;
|
||||||
|
final bool canUpdate;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final Future<void> Function(bool value)? onToggle;
|
||||||
|
final VoidCallback? onOpenSetting;
|
||||||
|
final VoidCallback? onUpdate;
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
final VoidCallback? onInstall;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final installed = item.installed;
|
||||||
|
final store = item.store;
|
||||||
|
final installedFlag = item.isInstalled;
|
||||||
|
|
||||||
|
final content = Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).dividerColor.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildCardIcon(item),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${item.author} · v${item.version}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (installed != null) ...[
|
||||||
|
Transform.scale(
|
||||||
|
scale: 0.82,
|
||||||
|
child: Switch(
|
||||||
|
value: !installed.disabled,
|
||||||
|
onChanged: busy || onToggle == null
|
||||||
|
? null
|
||||||
|
: (value) async {
|
||||||
|
await onToggle!(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
item.description,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (store != null) ...[
|
||||||
|
_metricItem(
|
||||||
|
context, Icons.star_rounded, item.stars.toString()),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_metricItem(context, Icons.download_outlined,
|
||||||
|
item.installCount.toString()),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (installed != null && canUpdate)
|
||||||
|
IconButton.filledTonal(
|
||||||
|
tooltip: 'newVersionUpdate'.tr,
|
||||||
|
onPressed: busy ? null : onUpdate,
|
||||||
|
icon: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh_rounded),
|
||||||
|
),
|
||||||
|
if (!installedFlag && store != null)
|
||||||
|
IconButton.filledTonal(
|
||||||
|
tooltip: 'extensionInstall'.tr,
|
||||||
|
onPressed: busy ? null : onInstall,
|
||||||
|
icon: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.download),
|
||||||
|
),
|
||||||
|
if ((item.homepage ?? '').isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'homepage'.tr,
|
||||||
|
onPressed: () => launchUrl(Uri.parse(item.homepage!)),
|
||||||
|
icon: const Icon(Icons.home_outlined),
|
||||||
|
),
|
||||||
|
if ((item.repoUrl ?? '').isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'GitHub',
|
||||||
|
onPressed: () => launchUrl(Uri.parse(item.repoUrl!)),
|
||||||
|
icon: const Icon(Icons.code),
|
||||||
|
),
|
||||||
|
if (installed != null &&
|
||||||
|
installed.settings?.isNotEmpty == true)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'setting'.tr,
|
||||||
|
onPressed: busy ? null : onOpenSetting,
|
||||||
|
icon: const Icon(Icons.settings),
|
||||||
|
),
|
||||||
|
if (installed != null)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'delete'.tr,
|
||||||
|
onPressed: busy ? null : onDelete,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (installedFlag && canUpdate)
|
||||||
|
Positioned(
|
||||||
|
top: 10,
|
||||||
|
right: 10,
|
||||||
|
child: _updateDot(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onTap == null) return content;
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: onTap,
|
||||||
|
child: content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCardIcon(ExtensionListItem item) {
|
||||||
|
final storeIcon = item.icon;
|
||||||
|
if (storeIcon != null && storeIcon.isNotEmpty) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.network(
|
||||||
|
storeIcon,
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => Image.asset(
|
||||||
|
'assets/extension/default_icon.png',
|
||||||
|
width: 42,
|
||||||
|
height: 42),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final extension = item.installed;
|
||||||
|
if (extension == null) {
|
||||||
|
return Image.asset('assets/extension/default_icon.png',
|
||||||
|
width: 42, height: 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
final image = extension.icon.isEmpty
|
||||||
|
? Image.asset('assets/extension/default_icon.png',
|
||||||
|
width: 42, height: 42)
|
||||||
|
: Util.isWeb()
|
||||||
|
? Image.network(
|
||||||
|
join('/fs/extensions/${extension.identity}/${extension.icon}'),
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ${Database.instance.getWebToken()}'
|
||||||
|
},
|
||||||
|
errorBuilder: (_, __, ___) => Image.asset(
|
||||||
|
'assets/extension/default_icon.png',
|
||||||
|
width: 42,
|
||||||
|
height: 42),
|
||||||
|
)
|
||||||
|
: Image.file(
|
||||||
|
extension.devMode
|
||||||
|
? File(path.join(extension.devPath, extension.icon))
|
||||||
|
: File(path.join(Util.getStorageDir(), 'extensions',
|
||||||
|
extension.identity, extension.icon)),
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
errorBuilder: (_, __, ___) => Image.asset(
|
||||||
|
'assets/extension/default_icon.png',
|
||||||
|
width: 42,
|
||||||
|
height: 42),
|
||||||
|
);
|
||||||
|
|
||||||
|
return ClipRRect(borderRadius: BorderRadius.circular(8), child: image);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _metricItem(BuildContext context, IconData icon, String text) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: Theme.of(context).hintColor),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(text, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _updateDot() {
|
||||||
|
return Container(
|
||||||
|
width: 9,
|
||||||
|
height: 9,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.redAccent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../../../../api/model/extension.dart';
|
||||||
|
import '../../../../api/model/store_extension.dart';
|
||||||
|
import '../../../../util/message.dart';
|
||||||
|
import '../../../../util/util.dart';
|
||||||
|
import '../controllers/extension_controller.dart';
|
||||||
|
|
||||||
|
class ExtensionDetailDrawer extends GetView<ExtensionController> {
|
||||||
|
const ExtensionDetailDrawer({
|
||||||
|
super.key,
|
||||||
|
required this.extension,
|
||||||
|
required this.onClose,
|
||||||
|
this.installed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final StoreExtension extension;
|
||||||
|
final Extension? installed;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 12, 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
extension.title,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: onClose,
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: Obx(() {
|
||||||
|
final localInstalled =
|
||||||
|
installed ?? controller.findInstalled(extension);
|
||||||
|
final canUpdate = controller.canUpdateFromStore(extension);
|
||||||
|
final busy =
|
||||||
|
controller.busyExtensionIds.contains(extension.id) ||
|
||||||
|
(localInstalled != null &&
|
||||||
|
controller.busyExtensionIds
|
||||||
|
.contains(localInstalled.identity));
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildIcon(),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${extension.author} • v${extension.version}'),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
extension.description,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
if (localInstalled == null)
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: busy
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
try {
|
||||||
|
await controller
|
||||||
|
.installFromStore(extension);
|
||||||
|
showMessage('tip'.tr,
|
||||||
|
'extensionInstallSuccess'.tr);
|
||||||
|
} catch (e) {
|
||||||
|
showErrorMessage(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.download),
|
||||||
|
label: Text('extensionInstall'.tr),
|
||||||
|
),
|
||||||
|
if (localInstalled != null && canUpdate)
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: busy
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
try {
|
||||||
|
await controller
|
||||||
|
.upgradeExtension(localInstalled);
|
||||||
|
showMessage('tip'.tr,
|
||||||
|
'extensionUpdateSuccess'.tr);
|
||||||
|
} catch (e) {
|
||||||
|
showErrorMessage(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh_rounded),
|
||||||
|
label: Text('newVersionUpdate'.tr),
|
||||||
|
),
|
||||||
|
if ((extension.homepage ?? '').isNotEmpty)
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
launchUrl(Uri.parse(extension.homepage!)),
|
||||||
|
icon: const Icon(Icons.home_outlined),
|
||||||
|
label: Text('homepage'.tr),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
launchUrl(Uri.parse(extension.repoUrl)),
|
||||||
|
icon: const Icon(Icons.code),
|
||||||
|
label: const Text('GitHub'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildReadme(context, localInstalled),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildReadme(BuildContext context, Extension? installed) {
|
||||||
|
return FutureBuilder<_ReadmeInfo>(
|
||||||
|
future: _loadReadme(installed),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final info = snapshot.data;
|
||||||
|
final markdown = info?.content ?? extension.readme ?? '';
|
||||||
|
if (markdown.trim().isEmpty) {
|
||||||
|
return Text('No README',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium);
|
||||||
|
}
|
||||||
|
|
||||||
|
return MarkdownBody(
|
||||||
|
data: markdown,
|
||||||
|
selectable: true,
|
||||||
|
onTapLink: (text, href, title) {
|
||||||
|
if (href == null || href.isEmpty) return;
|
||||||
|
final resolved = _resolvePath(href, info, forImage: false);
|
||||||
|
if (resolved == null) return;
|
||||||
|
launchUrl(Uri.parse(resolved),
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
|
},
|
||||||
|
imageBuilder: (uri, title, alt) {
|
||||||
|
final resolved = _resolvePath(uri.toString(), info, forImage: true);
|
||||||
|
if (resolved == null) return const SizedBox.shrink();
|
||||||
|
if (resolved.startsWith('file://')) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Image.file(
|
||||||
|
File(Uri.parse(resolved).toFilePath()),
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Image.network(
|
||||||
|
resolved,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_ReadmeInfo> _loadReadme(Extension? installed) async {
|
||||||
|
if (installed == null) {
|
||||||
|
return _ReadmeInfo(
|
||||||
|
content: extension.readme ?? '',
|
||||||
|
mode: _ReadmeMode.remote,
|
||||||
|
localReadmePath: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final rootDir = installed.devMode
|
||||||
|
? installed.devPath
|
||||||
|
: path.join(Util.getStorageDir(), 'extensions', installed.identity);
|
||||||
|
final candidates = [
|
||||||
|
path.join(rootDir, 'README.md'),
|
||||||
|
path.join(rootDir, 'readme.md'),
|
||||||
|
path.join(rootDir, 'README.MD'),
|
||||||
|
];
|
||||||
|
for (final filePath in candidates) {
|
||||||
|
final file = File(filePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
return _ReadmeInfo(
|
||||||
|
content: await file.readAsString(),
|
||||||
|
mode: _ReadmeMode.local,
|
||||||
|
localReadmePath: filePath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _ReadmeInfo(
|
||||||
|
content: extension.readme ?? '',
|
||||||
|
mode: _ReadmeMode.remote,
|
||||||
|
localReadmePath: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _resolvePath(String raw, _ReadmeInfo? info,
|
||||||
|
{required bool forImage}) {
|
||||||
|
final value = raw.trim();
|
||||||
|
if (value.isEmpty) return null;
|
||||||
|
final uri = Uri.tryParse(value);
|
||||||
|
if (uri != null && uri.hasScheme) {
|
||||||
|
return uri.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info?.mode == _ReadmeMode.local &&
|
||||||
|
info?.localReadmePath != null &&
|
||||||
|
forImage) {
|
||||||
|
final readmeDir = path.dirname(info!.localReadmePath!);
|
||||||
|
final clean = value.split('#').first;
|
||||||
|
final absolute = path.normalize(path.join(readmeDir, clean));
|
||||||
|
return Uri.file(absolute).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
final ref =
|
||||||
|
extension.commitSha?.isNotEmpty == true ? extension.commitSha! : 'HEAD';
|
||||||
|
final dir = (extension.directory ?? '').trim();
|
||||||
|
final baseSegments = [
|
||||||
|
if (dir.isNotEmpty) ...dir.split('/').where((e) => e.isNotEmpty),
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
final base = forImage
|
||||||
|
? Uri.https('raw.githubusercontent.com',
|
||||||
|
'/${extension.repoFullName}/$ref/${baseSegments.join('/')}')
|
||||||
|
: Uri.https('github.com',
|
||||||
|
'/${extension.repoFullName}/blob/$ref/${baseSegments.join('/')}');
|
||||||
|
return base.resolve(value).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildIcon() {
|
||||||
|
if ((extension.icon ?? '').isEmpty) {
|
||||||
|
return Image.asset('assets/extension/default_icon.png',
|
||||||
|
width: 56, height: 56);
|
||||||
|
}
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Image.network(
|
||||||
|
extension.icon!,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) {
|
||||||
|
return Image.asset('assets/extension/default_icon.png',
|
||||||
|
width: 56, height: 56);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _ReadmeMode {
|
||||||
|
remote,
|
||||||
|
local,
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReadmeInfo {
|
||||||
|
final String content;
|
||||||
|
final _ReadmeMode mode;
|
||||||
|
final String? localReadmePath;
|
||||||
|
|
||||||
|
_ReadmeInfo({
|
||||||
|
required this.content,
|
||||||
|
required this.mode,
|
||||||
|
required this.localReadmePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:badges/badges.dart' as badges;
|
import 'package:badges/badges.dart' as badges;
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
@@ -497,6 +498,27 @@ class SettingView extends GetView<SettingController> {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final buildDesktopNotification = !Util.isDesktop()
|
||||||
|
? () => null
|
||||||
|
: _buildConfigItem('desktopNotification', () {
|
||||||
|
return appController.downloaderConfig.value.extra.desktopNotification
|
||||||
|
? 'on'.tr
|
||||||
|
: 'off'.tr;
|
||||||
|
}, (Key key) {
|
||||||
|
return Container(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Switch(
|
||||||
|
value: appController.downloaderConfig.value.extra.desktopNotification,
|
||||||
|
onChanged: (bool value) async {
|
||||||
|
appController.downloaderConfig.update((val) {
|
||||||
|
val!.extra.desktopNotification = value;
|
||||||
|
});
|
||||||
|
await debounceSave();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// http config items start
|
// http config items start
|
||||||
final httpConfig = downloaderCfg.value.protocolConfig.http;
|
final httpConfig = downloaderCfg.value.protocolConfig.http;
|
||||||
final buildHttpUa =
|
final buildHttpUa =
|
||||||
@@ -1432,6 +1454,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
|
// advanced config log items start
|
||||||
buildLogsDir() {
|
buildLogsDir() {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
@@ -1505,6 +1613,7 @@ class SettingView extends GetView<SettingController> {
|
|||||||
buildBrowserExtension(),
|
buildBrowserExtension(),
|
||||||
buildAutoStartup(),
|
buildAutoStartup(),
|
||||||
buildMenubarMode(),
|
buildMenubarMode(),
|
||||||
|
buildDesktopNotification(),
|
||||||
]),
|
]),
|
||||||
)),
|
)),
|
||||||
Text('archives'.tr),
|
Text('archives'.tr),
|
||||||
@@ -1603,6 +1712,7 @@ class SettingView extends GetView<SettingController> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: _addDivider([
|
children: _addDivider([
|
||||||
buildWebhook(),
|
buildWebhook(),
|
||||||
|
if (Util.isDesktop()) buildScript(),
|
||||||
buildLogsDir(),
|
buildLogsDir(),
|
||||||
]),
|
]),
|
||||||
)),
|
)),
|
||||||
@@ -1734,6 +1844,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}) {
|
void _showGithubMirrorDialog({int? index, GithubMirror? initialMirror}) {
|
||||||
final isEdit = index != null;
|
final isEdit = index != null;
|
||||||
GithubMirrorType selectedType =
|
GithubMirrorType selectedType =
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../../api/api.dart';
|
||||||
|
import '../../api/model/task.dart';
|
||||||
|
import '../../util/util.dart';
|
||||||
|
import '../modules/app/controllers/app_controller.dart';
|
||||||
|
|
||||||
|
class NotificationService extends GetxService {
|
||||||
|
Timer? _timer;
|
||||||
|
final Map<String, Status> _previousStatus = {};
|
||||||
|
|
||||||
|
final AppController appController = Get.find<AppController>();
|
||||||
|
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
||||||
|
FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
_initNotifications();
|
||||||
|
_startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initNotifications() async {
|
||||||
|
const DarwinInitializationSettings initializationSettingsDarwin =
|
||||||
|
DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: false,
|
||||||
|
requestBadgePermission: false,
|
||||||
|
requestSoundPermission: false);
|
||||||
|
|
||||||
|
final LinuxInitializationSettings initializationSettingsLinux =
|
||||||
|
LinuxInitializationSettings(
|
||||||
|
defaultActionName: 'Open notification',
|
||||||
|
defaultIcon: AssetsLinuxIcon('assets/icon/icon.png'),
|
||||||
|
);
|
||||||
|
|
||||||
|
String? windowsIconPath;
|
||||||
|
try {
|
||||||
|
if (Util.isWindows()) {
|
||||||
|
final byteData = await rootBundle.load('assets/icon/icon.ico');
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final file = File('${tempDir.path}/notification_icon.ico');
|
||||||
|
await file.writeAsBytes(byteData.buffer
|
||||||
|
.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes));
|
||||||
|
windowsIconPath = file.path;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
final WindowsInitializationSettings initializationSettingsWindows =
|
||||||
|
WindowsInitializationSettings(
|
||||||
|
appName: 'Gopeed',
|
||||||
|
appUserModelId: 'com.gopeed.gopeed',
|
||||||
|
guid: '3c1bf3f4-3d91-4eaa-a33f-8705e71cf1ce', // unique guid
|
||||||
|
iconPath: windowsIconPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
final InitializationSettings initializationSettings =
|
||||||
|
InitializationSettings(
|
||||||
|
macOS: initializationSettingsDarwin,
|
||||||
|
linux: initializationSettingsLinux,
|
||||||
|
windows: initializationSettingsWindows,
|
||||||
|
);
|
||||||
|
|
||||||
|
await flutterLocalNotificationsPlugin.initialize(
|
||||||
|
settings: initializationSettings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startPolling() {
|
||||||
|
_timer = Timer.periodic(const Duration(seconds: 2), (timer) async {
|
||||||
|
try {
|
||||||
|
final config = appController.downloaderConfig.value;
|
||||||
|
if (!config.extra.desktopNotification) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final tasks = await getTasks([
|
||||||
|
Status.ready,
|
||||||
|
Status.running,
|
||||||
|
Status.pause,
|
||||||
|
Status.wait,
|
||||||
|
Status.error,
|
||||||
|
Status.done,
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (var task in tasks) {
|
||||||
|
final prevStatus = _previousStatus[task.id];
|
||||||
|
final currentStatus = task.status;
|
||||||
|
|
||||||
|
if (prevStatus != null && prevStatus != currentStatus) {
|
||||||
|
if (currentStatus == Status.done) {
|
||||||
|
_showNotification(
|
||||||
|
title: 'notificationTaskDone'.tr,
|
||||||
|
body: task.name,
|
||||||
|
);
|
||||||
|
} else if (currentStatus == Status.error) {
|
||||||
|
_showNotification(
|
||||||
|
title: 'notificationTaskError'.tr,
|
||||||
|
body: task.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_previousStatus[task.id] = currentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up deleted tasks from map
|
||||||
|
final currentTaskIds = tasks.map((t) => t.id).toSet();
|
||||||
|
_previousStatus
|
||||||
|
.removeWhere((id, status) => !currentTaskIds.contains(id));
|
||||||
|
} catch (e) {
|
||||||
|
// Ignored
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
int _notificationId = 0;
|
||||||
|
|
||||||
|
Future<void> _showNotification(
|
||||||
|
{required String title, required String body}) async {
|
||||||
|
const NotificationDetails notificationDetails = NotificationDetails(
|
||||||
|
macOS: DarwinNotificationDetails(),
|
||||||
|
linux: LinuxNotificationDetails(),
|
||||||
|
windows: WindowsNotificationDetails(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await flutterLocalNotificationsPlugin.show(
|
||||||
|
id: _notificationId++,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
notificationDetails: notificationDetails,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'package:contextmenu_plus/contextmenu_plus.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_context_menu/flutter_context_menu.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
import '../../api/api.dart';
|
import '../../api/api.dart';
|
||||||
|
import '../../api/model/request.dart';
|
||||||
|
import '../../api/model/resolve_task.dart';
|
||||||
import '../../api/model/task.dart';
|
import '../../api/model/task.dart';
|
||||||
import '../../util/message.dart';
|
import '../../util/message.dart';
|
||||||
import '../../util/util.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() {
|
List<Widget> buildActions() {
|
||||||
final list = <Widget>[];
|
final list = <Widget>[];
|
||||||
if (isDone()) {
|
if (isDone()) {
|
||||||
@@ -159,29 +317,6 @@ class BuildTaskListView extends GetView {
|
|||||||
return list;
|
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() {
|
double getProgress() {
|
||||||
final totalSize = task.meta.res?.size ?? 0;
|
final totalSize = task.meta.res?.size ?? 0;
|
||||||
return totalSize <= 0 ? 0 : task.progress.downloaded / totalSize;
|
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 taskController = Get.find<TaskController>();
|
||||||
final taskListController = taskController.tabIndex.value == 0
|
final taskListController = taskController.tabIndex.value == 0
|
||||||
? Get.find<TaskDownloadingController>()
|
? Get.find<TaskDownloadingController>()
|
||||||
@@ -268,19 +404,24 @@ class BuildTaskListView extends GetView {
|
|||||||
.where((id) => tasks.any((task) => task.id == id))
|
.where((id) => tasks.any((task) => task.id == id))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return ContextMenuArea(
|
// Build context menu entries
|
||||||
width: 140,
|
final contextMenuEntries = <ContextMenuEntry>[
|
||||||
builder: (context) => [
|
MenuItem(
|
||||||
buildContextItem(Icons.checklist, 'selectAll'.tr, () {
|
icon: const Icon(Icons.checklist),
|
||||||
|
label: Text('selectAll'.tr),
|
||||||
|
onSelected: (_) {
|
||||||
if (tasks.isEmpty) return;
|
if (tasks.isEmpty) return;
|
||||||
|
|
||||||
if (selectedTaskIds.isNotEmpty) {
|
if (selectedTaskIds.isNotEmpty) {
|
||||||
taskListController.selectedTaskIds([]);
|
taskListController.selectedTaskIds([]);
|
||||||
} else {
|
} else {
|
||||||
taskListController.selectedTaskIds(tasks.map((e) => e.id).toList());
|
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()) {
|
if (isSelect()) {
|
||||||
taskListController.selectedTaskIds(taskListController
|
taskListController.selectedTaskIds(taskListController
|
||||||
.selectedTaskIds
|
.selectedTaskIds
|
||||||
@@ -290,36 +431,89 @@ class BuildTaskListView extends GetView {
|
|||||||
taskListController.selectedTaskIds(
|
taskListController.selectedTaskIds(
|
||||||
[...taskListController.selectedTaskIds, task.id]);
|
[...taskListController.selectedTaskIds, task.id]);
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
const Divider(
|
),
|
||||||
indent: 8,
|
const MenuDivider(),
|
||||||
endIndent: 8,
|
MenuItem(
|
||||||
),
|
icon: const Icon(Icons.play_arrow),
|
||||||
buildContextItem(Icons.play_arrow, 'continue'.tr, () async {
|
label: Text('continue'.tr),
|
||||||
|
enabled: !isDone() && !isRunning(),
|
||||||
|
onSelected: (_) async {
|
||||||
try {
|
try {
|
||||||
await continueAllTasks(filterSelectedTaskIds(
|
await continueAllTasks(filterSelectedTaskIds(
|
||||||
{...taskListController.selectedTaskIds, task.id}));
|
{...taskListController.selectedTaskIds, task.id}));
|
||||||
} finally {
|
} finally {
|
||||||
taskListController.selectedTaskIds([]);
|
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 {
|
try {
|
||||||
await pauseAllTasks(filterSelectedTaskIds(
|
await pauseAllTasks(filterSelectedTaskIds(
|
||||||
{...taskListController.selectedTaskIds, task.id}));
|
{...taskListController.selectedTaskIds, task.id}));
|
||||||
} finally {
|
} finally {
|
||||||
taskListController.selectedTaskIds([]);
|
taskListController.selectedTaskIds([]);
|
||||||
}
|
}
|
||||||
}, enabled: !isDone() && isRunning()),
|
},
|
||||||
buildContextItem(Icons.delete, 'delete'.tr, () async {
|
),
|
||||||
|
MenuItem(
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
label: Text('delete'.tr),
|
||||||
|
onSelected: (_) async {
|
||||||
try {
|
try {
|
||||||
await showDeleteDialog(filterSelectedTaskIds(
|
await showDeleteDialog(filterSelectedTaskIds(
|
||||||
{...taskListController.selectedTaskIds, task.id}));
|
{...taskListController.selectedTaskIds, task.id}));
|
||||||
} finally {
|
} finally {
|
||||||
taskListController.selectedTaskIds([]);
|
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(
|
child: Obx(
|
||||||
() => Card(
|
() => Card(
|
||||||
elevation: 4.0,
|
elevation: 4.0,
|
||||||
@@ -344,7 +538,24 @@ class BuildTaskListView extends GetView {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
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(
|
leading: Icon(
|
||||||
fileIcon(task.name,
|
fileIcon(task.name,
|
||||||
isFolder: isFolderTask(),
|
isFolder: isFolderTask(),
|
||||||
|
|||||||
@@ -164,5 +164,8 @@ const deDE = {
|
|||||||
'categoryPath': 'Kategoriepfad',
|
'categoryPath': 'Kategoriepfad',
|
||||||
'builtInCategory': 'Integrierte Kategorie kann nicht gelöscht werden',
|
'builtInCategory': 'Integrierte Kategorie kann nicht gelöscht werden',
|
||||||
'selectCategory': 'Kategorie auswählen',
|
'selectCategory': 'Kategorie auswählen',
|
||||||
|
'desktopNotification': 'Desktop-Benachrichtigungen',
|
||||||
|
'notificationTaskDone': 'Aufgabe abgeschlossen',
|
||||||
|
'notificationTaskError': 'Aufgabenfehler',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -80,6 +80,10 @@ const enUS = {
|
|||||||
'webhookTest': 'Test',
|
'webhookTest': 'Test',
|
||||||
'webhookTestSuccess': 'Webhook test successful',
|
'webhookTestSuccess': 'Webhook test successful',
|
||||||
'webhookTestFail': 'Webhook test failed',
|
'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',
|
'urlInvalid': 'Please enter a valid HTTP or HTTPS URL',
|
||||||
'required': 'This field is required',
|
'required': 'This field is required',
|
||||||
'show': 'Show',
|
'show': 'Show',
|
||||||
@@ -103,6 +107,27 @@ const enUS = {
|
|||||||
'extensionAlreadyLatest': 'It\'s already the latest version',
|
'extensionAlreadyLatest': 'It\'s already the latest version',
|
||||||
'extensionFind': 'Find Extensions',
|
'extensionFind': 'Find Extensions',
|
||||||
'extensionDevelop': 'Develop Extensions',
|
'extensionDevelop': 'Develop Extensions',
|
||||||
|
'extensionStoreTitle': 'Extension Store',
|
||||||
|
'extensionStoreSection': 'Store Extensions',
|
||||||
|
'extensionInstalledSection': 'Installed Extensions',
|
||||||
|
'extensionInstalledEmpty': 'No extensions installed yet',
|
||||||
|
'extensionStoreEmpty': 'No matching extensions found',
|
||||||
|
'extensionSearchHint': 'Search by name, title, description or author',
|
||||||
|
'extensionSortStars': 'Most Stars',
|
||||||
|
'extensionSortInstalls': 'Most Installs',
|
||||||
|
'extensionSortUpdated': 'Recently Updated',
|
||||||
|
'extensionSortToggle': 'Toggle sort direction',
|
||||||
|
'extensionLoadMore': 'Load More',
|
||||||
|
'extensionNoMore': 'No more extensions',
|
||||||
|
'extensionInstalled': 'Installed',
|
||||||
|
'extensionCanUpdate': 'Update Available',
|
||||||
|
'extensionInstall': 'Install',
|
||||||
|
'extensionLoadLocal': 'Install Local Folder',
|
||||||
|
'extensionFilterAll': 'All',
|
||||||
|
'extensionFilterMarket': 'Market',
|
||||||
|
'extensionFilterInstalled': 'Installed',
|
||||||
|
'extensionManualInstall': 'Manual Install',
|
||||||
|
'extensionInstallTools': 'Install Options',
|
||||||
'history': 'History',
|
'history': 'History',
|
||||||
'clearHistory': 'Clear History',
|
'clearHistory': 'Clear History',
|
||||||
'noHistoryFound': 'No History Found',
|
'noHistoryFound': 'No History Found',
|
||||||
@@ -147,6 +172,7 @@ const enUS = {
|
|||||||
'unknown': 'Unknown',
|
'unknown': 'Unknown',
|
||||||
'fileSelectedCount': 'Files: ',
|
'fileSelectedCount': 'Files: ',
|
||||||
'fileSelectedSize': 'Size: ',
|
'fileSelectedSize': 'Size: ',
|
||||||
|
'httpHeader': 'Header',
|
||||||
'httpHeaderName': 'Header Name',
|
'httpHeaderName': 'Header Name',
|
||||||
'httpHeaderValue': 'Header Value',
|
'httpHeaderValue': 'Header Value',
|
||||||
'login': 'Login',
|
'login': 'Login',
|
||||||
@@ -177,5 +203,19 @@ const enUS = {
|
|||||||
'githubMirrorType': 'Mirror Type',
|
'githubMirrorType': 'Mirror Type',
|
||||||
'githubMirrorUrl': 'Mirror URL',
|
'githubMirrorUrl': 'Mirror URL',
|
||||||
'githubMirrorUrlHint': 'Enter 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',
|
||||||
|
'desktopNotification': 'Desktop Notifications',
|
||||||
|
'notificationTaskDone': 'Task completed',
|
||||||
|
'notificationTaskError': 'Task error',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -142,5 +142,8 @@ const esES = {
|
|||||||
'categoryPath': 'Ruta de categoría',
|
'categoryPath': 'Ruta de categoría',
|
||||||
'builtInCategory': 'La categoría incorporada no se puede eliminar',
|
'builtInCategory': 'La categoría incorporada no se puede eliminar',
|
||||||
'selectCategory': 'Seleccionar categoría',
|
'selectCategory': 'Seleccionar categoría',
|
||||||
|
'desktopNotification': 'Notificaciones de escritorio',
|
||||||
|
'notificationTaskDone': 'Tarea completada',
|
||||||
|
'notificationTaskError': 'Error de tarea',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -93,5 +93,8 @@ const faIR = {
|
|||||||
'launchAtStartup': 'راهاندازی در استارتآپ',
|
'launchAtStartup': 'راهاندازی در استارتآپ',
|
||||||
'runAsMenubarApp': 'اجرا به عنوان برنامه نوار منو',
|
'runAsMenubarApp': 'اجرا به عنوان برنامه نوار منو',
|
||||||
'runAsMenubarAppDesc': 'پنهان کردن آیکون Dock و اجرا فقط در نوار منو',
|
'runAsMenubarAppDesc': 'پنهان کردن آیکون Dock و اجرا فقط در نوار منو',
|
||||||
|
'desktopNotification': 'اعلانهای دسکتاپ',
|
||||||
|
'notificationTaskDone': 'وظیفه تکمیل شد',
|
||||||
|
'notificationTaskError': 'خطای وظیفه',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -135,5 +135,8 @@ const frFR = {
|
|||||||
'categoryPath': 'Chemin de la catégorie',
|
'categoryPath': 'Chemin de la catégorie',
|
||||||
'builtInCategory': 'La catégorie intégrée ne peut pas être supprimée',
|
'builtInCategory': 'La catégorie intégrée ne peut pas être supprimée',
|
||||||
'selectCategory': 'Sélectionner une catégorie',
|
'selectCategory': 'Sélectionner une catégorie',
|
||||||
|
'desktopNotification': 'Notifications de bureau',
|
||||||
|
'notificationTaskDone': 'Tâche terminée',
|
||||||
|
'notificationTaskError': 'Erreur de tâche',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -156,5 +156,8 @@ const huHU = {
|
|||||||
'categoryPath': 'Kategória útvonala',
|
'categoryPath': 'Kategória útvonala',
|
||||||
'builtInCategory': 'A beépített kategória nem törölhető',
|
'builtInCategory': 'A beépített kategória nem törölhető',
|
||||||
'selectCategory': 'Kategória kiválasztása',
|
'selectCategory': 'Kategória kiválasztása',
|
||||||
|
'desktopNotification': 'Asztali értesítések',
|
||||||
|
'notificationTaskDone': 'Feladat befejezve',
|
||||||
|
'notificationTaskError': 'Feladat hiba',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -142,5 +142,8 @@ const idID = {
|
|||||||
'categoryPath': 'Path Kategori',
|
'categoryPath': 'Path Kategori',
|
||||||
'builtInCategory': 'Kategori bawaan tidak dapat dihapus',
|
'builtInCategory': 'Kategori bawaan tidak dapat dihapus',
|
||||||
'selectCategory': 'Pilih Kategori',
|
'selectCategory': 'Pilih Kategori',
|
||||||
|
'desktopNotification': 'Notifikasi Desktop',
|
||||||
|
'notificationTaskDone': 'Tugas selesai',
|
||||||
|
'notificationTaskError': 'Tugas error',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -130,5 +130,8 @@ const itIT = {
|
|||||||
'categoryPath': 'Percorso categoria',
|
'categoryPath': 'Percorso categoria',
|
||||||
'builtInCategory': 'La categoria predefinita non può essere eliminata',
|
'builtInCategory': 'La categoria predefinita non può essere eliminata',
|
||||||
'selectCategory': 'Seleziona categoria',
|
'selectCategory': 'Seleziona categoria',
|
||||||
|
'desktopNotification': 'Notifiche Desktop',
|
||||||
|
'notificationTaskDone': 'Attività completata',
|
||||||
|
'notificationTaskError': 'Errore attività',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -95,5 +95,8 @@ const jaJP = {
|
|||||||
'launchAtStartup': '起動時に起動',
|
'launchAtStartup': '起動時に起動',
|
||||||
'runAsMenubarApp': 'メニューバーアプリとして実行',
|
'runAsMenubarApp': 'メニューバーアプリとして実行',
|
||||||
'runAsMenubarAppDesc': 'Dockアイコンを非表示にして、メニューバーのみで実行',
|
'runAsMenubarAppDesc': 'Dockアイコンを非表示にして、メニューバーのみで実行',
|
||||||
|
'desktopNotification': 'デスクトップ通知',
|
||||||
|
'notificationTaskDone': 'タスク完了',
|
||||||
|
'notificationTaskError': 'タスクエラー',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -127,5 +127,8 @@ const plPL = {
|
|||||||
'categoryPath': 'Ścieżka kategorii',
|
'categoryPath': 'Ścieżka kategorii',
|
||||||
'builtInCategory': 'Wbudowanej kategorii nie można usunąć',
|
'builtInCategory': 'Wbudowanej kategorii nie można usunąć',
|
||||||
'selectCategory': 'Wybierz kategorię',
|
'selectCategory': 'Wybierz kategorię',
|
||||||
|
'desktopNotification': 'Powiadomienia na pulpicie',
|
||||||
|
'notificationTaskDone': 'Zadanie zakończone',
|
||||||
|
'notificationTaskError': 'Błąd zadania',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -153,5 +153,8 @@ const ptBR = {
|
|||||||
'categoryPath': 'Caminho da Categoria',
|
'categoryPath': 'Caminho da Categoria',
|
||||||
'builtInCategory': 'Categoria integrada não pode ser excluída',
|
'builtInCategory': 'Categoria integrada não pode ser excluída',
|
||||||
'selectCategory': 'Selecionar Categoria',
|
'selectCategory': 'Selecionar Categoria',
|
||||||
|
'desktopNotification': 'Notificações na área de trabalho',
|
||||||
|
'notificationTaskDone': 'Tarefa concluída',
|
||||||
|
'notificationTaskError': 'Erro na tarefa',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -152,5 +152,8 @@ const ruRU = {
|
|||||||
'categoryPath': 'Путь категории',
|
'categoryPath': 'Путь категории',
|
||||||
'builtInCategory': 'Встроенную категорию нельзя удалить',
|
'builtInCategory': 'Встроенную категорию нельзя удалить',
|
||||||
'selectCategory': 'Выбрать категорию',
|
'selectCategory': 'Выбрать категорию',
|
||||||
|
'desktopNotification': 'Уведомления',
|
||||||
|
'notificationTaskDone': 'Задача завершена',
|
||||||
|
'notificationTaskError': 'Ошибка задачи',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -129,5 +129,8 @@ const taTA = {
|
|||||||
'categoryPath': 'வகை பாதை',
|
'categoryPath': 'வகை பாதை',
|
||||||
'builtInCategory': 'உள்ளமைக்கப்பட்ட வகையை நீக்க முடியாது',
|
'builtInCategory': 'உள்ளமைக்கப்பட்ட வகையை நீக்க முடியாது',
|
||||||
'selectCategory': 'வகையைத் தேர்ந்தெடுக்கவும்',
|
'selectCategory': 'வகையைத் தேர்ந்தெடுக்கவும்',
|
||||||
|
'desktopNotification': 'கணினி அறிவிப்புகள்',
|
||||||
|
'notificationTaskDone': 'பணி முடிந்தது',
|
||||||
|
'notificationTaskError': 'பணி பிழை',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -4,29 +4,28 @@ const trTR = {
|
|||||||
'error': 'Hata',
|
'error': 'Hata',
|
||||||
'tip': 'İpucu',
|
'tip': 'İpucu',
|
||||||
'confirm': 'Onayla',
|
'confirm': 'Onayla',
|
||||||
'confirmDelete': 'Silmek istediğinizden emin misiniz?',
|
'confirmDelete': 'Silmek istediğinize emin misiniz?',
|
||||||
'cancel': 'İptal',
|
'cancel': 'İptal',
|
||||||
'on': 'Açık',
|
'on': 'Açık',
|
||||||
'off': 'Kapalı',
|
'off': 'Kapalı',
|
||||||
'selectAll': 'Tümünü Seç',
|
'selectAll': 'Tümünü Seç',
|
||||||
'select': 'Seç',
|
'select': 'Seç',
|
||||||
'task': 'Görevler',
|
'task': 'İşlemler',
|
||||||
'downloading': 'İndiriliyor',
|
'downloading': 'İndiriliyor',
|
||||||
'downloaded': 'İndirildi',
|
'downloaded': 'İndirildi',
|
||||||
'setting': 'Ayarlar',
|
'setting': 'Ayarlar',
|
||||||
'donate': 'Bağış Yap',
|
'donate': 'Bağış Yap',
|
||||||
'exit': 'Çıkış',
|
'exit': 'Çıkış',
|
||||||
'create': 'Görev Oluştur',
|
'create': 'İşlem Oluştur',
|
||||||
'directDownload': 'Doğrudan İndirme',
|
'directDownload': 'Direkt İndir',
|
||||||
'advancedOptions': 'Gelişmiş Seçenekler',
|
'advancedOptions': 'Gelişmiş Seçenekler',
|
||||||
'followSettings': 'Ayarları Takip Et',
|
'followSettings': 'Ayarları Takip Et',
|
||||||
'downloadLink': 'İndirme Bağlantısı',
|
'downloadLink': 'İndirme Bağlantısı',
|
||||||
'downloadLinkValid': 'Lütfen indirme bağlantısını girin',
|
'downloadLinkValid': 'Lütfen indirme bağlantısını girin',
|
||||||
'downloadLinkHit': 'Lütfen her satıra bir indirme bağlantısı girin@append',
|
'downloadLinkHit': 'Lütfen her satıra bir tane indirme bağlantısını girin@append',
|
||||||
'downloadLinkHitDesktop':
|
'downloadLinkHitDesktop': ', veya torrent dosyasını olarak buraya sürükleyin',
|
||||||
', veya doğrudan torrent dosyasını buraya sürükleyin',
|
|
||||||
'download': 'İndir',
|
'download': 'İndir',
|
||||||
'noFileSelected': 'Devam etmek için en az bir dosya seçin.',
|
'noFileSelected': 'Devam etmek için lütfen en az bir dosya seçin.',
|
||||||
'noStoragePermission': 'Depolama izni gerekli',
|
'noStoragePermission': 'Depolama izni gerekli',
|
||||||
'selectFile': 'Dosya Seç',
|
'selectFile': 'Dosya Seç',
|
||||||
'rename': 'Yeniden Adlandır',
|
'rename': 'Yeniden Adlandır',
|
||||||
@@ -34,15 +33,16 @@ const trTR = {
|
|||||||
'advanced': 'Gelişmiş',
|
'advanced': 'Gelişmiş',
|
||||||
'general': 'Genel',
|
'general': 'Genel',
|
||||||
'downloadDir': 'İndirme Dizini',
|
'downloadDir': 'İndirme Dizini',
|
||||||
'downloadDirValid': 'Lütfen bir indirme dizini seçin',
|
'downloadDirValid': 'Lütfen indirme dizinini seçin',
|
||||||
'connections': 'Bağlantılar',
|
'connections': 'Bağlantılar',
|
||||||
'useServerCtime': 'Dosya oluşturma için sunucu zamanını kullan',
|
'useServerCtime': 'Dosya oluşturma tarihi için sunucu zamanını kullan',
|
||||||
'maxRunning': 'Maksimum Çalışan Görev',
|
'maxRunning': 'Maksimum Çalışan İşlem',
|
||||||
'defaultDirectDownload': 'Varsayılan olarak doğrudan indirmeyi seç',
|
'defaultDirectDownload': 'Varsayılan olarak direkt indirmeyi işaretle',
|
||||||
'autoTorrentEnable': '.torrent dosyalarından otomatik BT görevi oluştur',
|
'autoStartTasks': 'Başlangıçta tamamlanmamış işlemleri otomatik başlat',
|
||||||
|
'autoTorrentEnable': '.torrent dosyalarından otomatik BitTorrent işlemleri oluştur',
|
||||||
'autoTorrentDeleteAfterDownload':
|
'autoTorrentDeleteAfterDownload':
|
||||||
'BT görevi oluşturulduktan sonra .torrent dosyasını sil',
|
'BitTorrent işlemi oluşturulduktan sonra .torrent dosyasını sil',
|
||||||
'autoDeleteMissingFileTasks': 'Eksik dosya görevlerini otomatik sil',
|
'autoDeleteMissingFileTasks': 'Eksik dosya işlemlerini otomatik sil',
|
||||||
'items': '@count öğe',
|
'items': '@count öğe',
|
||||||
'subscribeTracker': 'Tracker Aboneliği',
|
'subscribeTracker': 'Tracker Aboneliği',
|
||||||
'subscribeFail':
|
'subscribeFail':
|
||||||
@@ -51,44 +51,61 @@ const trTR = {
|
|||||||
'updateDaily': 'Günlük güncelle',
|
'updateDaily': 'Günlük güncelle',
|
||||||
'lastUpdate': 'Son güncelleme: @time',
|
'lastUpdate': 'Son güncelleme: @time',
|
||||||
'addTracker': 'Tracker Ekle',
|
'addTracker': 'Tracker Ekle',
|
||||||
'addTrackerHit':
|
'addTrackerHit': 'Lütfen her satıra bir tane olacak şekilde tracker sunucu adresini girin',
|
||||||
'Lütfen tracker sunucu URL’sini girin, her satıra bir tane',
|
|
||||||
'ui': 'Arayüz',
|
'ui': 'Arayüz',
|
||||||
'theme': 'Tema',
|
'theme': 'Tema',
|
||||||
'themeSystem': 'Sistem',
|
'themeSystem': 'Sistem',
|
||||||
'themeLight': 'Açık',
|
'themeLight': 'Açık',
|
||||||
'themeDark': 'Koyu',
|
'themeDark': 'Koyu',
|
||||||
'locale': 'Dil',
|
'locale': 'Dil',
|
||||||
|
'notifyWhenNewVersion': 'Güncellemeleri bildir',
|
||||||
|
'analyticsEnabled': 'İstatistikleri Yükle',
|
||||||
|
'analyticsEnabledDesc': 'Uygulamayı geliştirmemize yardımcı olmak için anonim kullanım verilerini paylaşın',
|
||||||
'about': 'Hakkında',
|
'about': 'Hakkında',
|
||||||
'homepage': 'Ana Sayfa',
|
'homepage': 'Ana Sayfa',
|
||||||
'version': 'Sürüm',
|
'version': 'Sürüm',
|
||||||
'protocol': 'Protokol',
|
'protocol': 'Protokol',
|
||||||
'port': 'Bağlantı Noktası',
|
'port': 'Port',
|
||||||
'apiToken': 'API Anahtarı',
|
'apiToken': 'API Anahtarı',
|
||||||
'notSet': 'AYARLANMADI',
|
'notSet': 'AYARLANMADI',
|
||||||
'set': 'AYARLA',
|
'set': 'AYARLA',
|
||||||
'portInUse': 'Bağlantı noktası [@port] kullanımda, lütfen değiştirin',
|
'portInUse': 'Port [@port] kullanımda, lütfen portu değiştirin',
|
||||||
'effectAfterRestart': 'Yeniden başlatmadan sonra etkili olacak',
|
'effectAfterRestart': 'Yeniden başlatmadan sonra etkili olacak',
|
||||||
'developer': 'Geliştirici',
|
'developer': 'Geliştirici',
|
||||||
'logDirectory': 'Günlük Dizini',
|
'logDirectory': 'Günlük Dizini',
|
||||||
|
'webhook': 'Webhook',
|
||||||
|
'webhookEnable': 'Webhook\'u Etkinleştir',
|
||||||
|
'webhookDesc': 'İşlemler tamamlandığında veya başarısız olduğunda HTTP POST bildirimleri gönder',
|
||||||
|
'webhookUrlHint': 'Webhook URL\'sini girin',
|
||||||
|
'webhookTest': 'Test',
|
||||||
|
'webhookTestSuccess': 'Webhook testi başarılı',
|
||||||
|
'webhookTestFail': 'Webhook testi başarısız',
|
||||||
|
'script': 'Betik',
|
||||||
|
'scriptEnable': 'Betiği Etkinleştir',
|
||||||
|
'scriptDesc': 'İndirmeler başarıyla tamamlandığında özel betikleri çalıştır',
|
||||||
|
'scriptPathHint': 'Betik dosya yolunu girin (örn. /path/to/script.sh)',
|
||||||
|
'urlInvalid': 'Lütfen geçerli bir HTTP veya HTTPS URL\'si girin',
|
||||||
|
'required': 'Bu alan zorunludur',
|
||||||
'show': 'Göster',
|
'show': 'Göster',
|
||||||
'continue': 'Devam Et',
|
'continue': 'Devam Et',
|
||||||
'pause': 'Duraklat',
|
'pause': 'Duraklat',
|
||||||
'startAll': 'Tümünü Başlat',
|
'startAll': 'Tümünü Başlat',
|
||||||
'pauseAll': 'Tümünü Duraklat',
|
'pauseAll': 'Tümünü Duraklat',
|
||||||
'deleteTask': '@count görevi sil',
|
'deleteTask': '@count işlemi sil',
|
||||||
'deleteTaskTip': 'İndirilen dosyaları sakla',
|
'deleteTaskTip': 'İndirilen dosyaları sakla',
|
||||||
'delete': 'Sil',
|
'delete': 'Sil',
|
||||||
'newVersionTitle': 'Yeni sürüm bulundu @version',
|
'add': 'Ekle',
|
||||||
|
'edit': 'Düzenle',
|
||||||
|
'newVersionTitle': 'Yeni sürüm @version keşfedildi',
|
||||||
'newVersionUpdate': 'Şimdi Güncelle',
|
'newVersionUpdate': 'Şimdi Güncelle',
|
||||||
'newVersionLater': 'Daha Sonra',
|
'newVersionLater': 'Daha Sonra',
|
||||||
'extensions': 'Eklentiler',
|
'extensions': 'Eklentiler',
|
||||||
'extensionInstallUrl': 'Kurulum URL’si',
|
'extensionInstallUrl': 'Kurulum URL\'si',
|
||||||
'extensionInstallSuccess': 'Başarıyla yüklendi',
|
'extensionInstallSuccess': 'Başarıyla kuruldu',
|
||||||
'extensionUpdateSuccess': 'Başarıyla güncellendi',
|
'extensionUpdateSuccess': 'Başarıyla güncellendi',
|
||||||
'extensionDelete': 'Eklentiyi Sil',
|
'extensionDelete': 'Eklentiyi Sil',
|
||||||
'extensionAlreadyLatest': 'Zaten en son sürüm',
|
'extensionAlreadyLatest': 'Zaten en son sürüm',
|
||||||
'extensionFind': 'Eklentileri Bul',
|
'extensionFind': 'Eklenti Bul',
|
||||||
'extensionDevelop': 'Eklenti Geliştir',
|
'extensionDevelop': 'Eklenti Geliştir',
|
||||||
'history': 'Geçmiş',
|
'history': 'Geçmiş',
|
||||||
'clearHistory': 'Geçmişi Temizle',
|
'clearHistory': 'Geçmişi Temizle',
|
||||||
@@ -96,36 +113,35 @@ const trTR = {
|
|||||||
'serviceTitle': 'İndirme Servisi',
|
'serviceTitle': 'İndirme Servisi',
|
||||||
'serviceText': 'Çalışıyor',
|
'serviceText': 'Çalışıyor',
|
||||||
'network': 'Ağ',
|
'network': 'Ağ',
|
||||||
'proxy': 'Vekil Sunucu',
|
'proxy': 'Proxy',
|
||||||
'noProxy': 'Vekil Sunucu Yok',
|
'noProxy': 'Proxy Yok',
|
||||||
'systemProxy': 'Sistem Vekil Sunucusu',
|
'systemProxy': 'Sistem Proxy\'si',
|
||||||
'customProxy': 'Özel Vekil Sunucu',
|
'customProxy': 'Özel Proxy',
|
||||||
'server': 'Sunucu',
|
'server': 'Sunucu',
|
||||||
'username': 'Kullanıcı Adı',
|
'username': 'Kullanıcı Adı',
|
||||||
'password': 'Şifre',
|
'password': 'Şifre',
|
||||||
'thanks': 'Teşekkürler',
|
'thanks': 'Teşekkürler',
|
||||||
'thanksDesc':
|
'thanksDesc':
|
||||||
'Gopeed topluluğunun inşasında ve geliştirilmesinde katkıda bulunan herkese teşekkürler!',
|
'Gopeed topluluğunu oluşturmaya ve geliştirmeye yardımcı olan herkese teşekkürler!',
|
||||||
'browserExtension': 'Tarayıcı Eklentisi',
|
'browserExtension': 'Tarayıcı Eklentisi',
|
||||||
'launchAtStartup': 'Başlangıçta Çalıştır',
|
'launchAtStartup': 'Başlangıçta çalıştır',
|
||||||
'runAsMenubarApp': 'Menü çubuğu uygulaması olarak çalıştır',
|
'runAsMenubarApp': 'Menü çubuğu uygulaması olarak çalıştır',
|
||||||
'runAsMenubarAppDesc':
|
'runAsMenubarAppDesc': 'Dock simgesini gizle ve sadece menü çubuğunda çalıştır',
|
||||||
'Dock simgesini gizle ve yalnızca menü çubuğunda çalıştır',
|
'seedConfig': 'Seed Yapılandırması',
|
||||||
'seedConfig': 'Seed Ayarları',
|
'seedKeep': 'Manuel olarak durdurulana kadar seed\'e devam et',
|
||||||
'seedKeep': 'Manuel olarak durdurulana kadar seed etmeye devam et',
|
|
||||||
'seedRatio': 'Seed oranı',
|
'seedRatio': 'Seed oranı',
|
||||||
'seedTime': 'Seed süresi (dakika)',
|
'seedTime': 'Seed süresi (dakika)',
|
||||||
'setAsDefaultBtClient': 'Varsayılan BT istemcisi olarak ayarla',
|
'setAsDefaultBtClient': 'Varsayılan BitTorrent istemcisi olarak ayarla',
|
||||||
'taskDetail': 'Görev Detayı',
|
'taskDetail': 'İşlem Detayı',
|
||||||
'taskName': 'Görev Adı',
|
'taskName': 'İşlem Adı',
|
||||||
'taskUrl': 'Görev URL’si',
|
'taskUrl': 'İşlem URL\'si',
|
||||||
'downloadPath': 'İndirme Yolu',
|
'downloadPath': 'İndirme Yolu',
|
||||||
'skipVerifyCert': 'Sertifika Doğrulamayı Atla',
|
'skipVerifyCert': 'Sertifika Doğrulamasını Atla',
|
||||||
'archives': 'Arşivler',
|
'archives': 'Arşivler',
|
||||||
'autoExtract': 'Arşivleri otomatik çıkart',
|
'autoExtract': 'Arşivleri Otomatik Çıkar',
|
||||||
'archivePassword': 'Arşiv şifresi',
|
'archivePassword': 'Arşiv Şifresi',
|
||||||
'archivePasswordHint': 'Şifre yoksa boş bırakın',
|
'archivePasswordHint': 'Şifre korumalı değilse boş bırakın',
|
||||||
'deleteAfterExtract': 'Çıkarmadan sonra arşivi sil',
|
'deleteAfterExtract': 'Çıkardıktan sonra arşivi sil',
|
||||||
'extracting': 'Çıkarılıyor',
|
'extracting': 'Çıkarılıyor',
|
||||||
'extractDone': 'Çıkarma tamamlandı',
|
'extractDone': 'Çıkarma tamamlandı',
|
||||||
'extractError': 'Çıkarma başarısız',
|
'extractError': 'Çıkarma başarısız',
|
||||||
@@ -133,15 +149,23 @@ const trTR = {
|
|||||||
'name': 'Ad',
|
'name': 'Ad',
|
||||||
'size': 'Boyut',
|
'size': 'Boyut',
|
||||||
'unknown': 'Bilinmeyen',
|
'unknown': 'Bilinmeyen',
|
||||||
'fileSelectedCount': 'Dosya: ',
|
'fileSelectedCount': 'Dosyalar: ',
|
||||||
'fileSelectedSize': 'Boyut: ',
|
'fileSelectedSize': 'Boyut: ',
|
||||||
'httpHeaderName': 'Başlık Adı',
|
'httpHeader': 'Üstbilgi (Header)',
|
||||||
'httpHeaderValue': 'Başlık Değeri',
|
'httpHeaderName': 'Üstbilgi Adı',
|
||||||
'insertPlaceholder': 'Yer tutucu ekle',
|
'httpHeaderValue': 'Üstbilgi Değeri',
|
||||||
'placeholderYear': 'Geçerli yıl',
|
'login': 'Giriş Yap',
|
||||||
'placeholderMonth': 'Geçerli ay (01-12)',
|
'username_required': 'Lütfen kullanıcı adınızı girin',
|
||||||
'placeholderDay': 'Geçerli gün (01-31)',
|
'password_required': 'Lütfen şifrenizi girin',
|
||||||
'placeholderDate': 'Tam tarih (YYYY-AA-GG)',
|
'login_success': 'Giriş başarılı',
|
||||||
|
'login_failed': 'Giriş başarısız, lütfen kullanıcı adınızı ve şifrenizi kontrol edin',
|
||||||
|
'login_failed_network':
|
||||||
|
'Giriş başarısız, lütfen ağ bağlantınızı kontrol edin',
|
||||||
|
'insertPlaceholder': 'Yer Tutucu Ekle',
|
||||||
|
'placeholderYear': 'Mevcut yıl',
|
||||||
|
'placeholderMonth': 'Mevcut ay (01-12)',
|
||||||
|
'placeholderDay': 'Mevcut gün (01-31)',
|
||||||
|
'placeholderDate': 'Tam tarih (YYYY-MM-DD)',
|
||||||
'example': 'örn. @value',
|
'example': 'örn. @value',
|
||||||
'downloadCategories': 'İndirme Kategorileri',
|
'downloadCategories': 'İndirme Kategorileri',
|
||||||
'categoryMusic': 'Müzik',
|
'categoryMusic': 'Müzik',
|
||||||
@@ -152,5 +176,25 @@ const trTR = {
|
|||||||
'categoryPath': 'Kategori Yolu',
|
'categoryPath': 'Kategori Yolu',
|
||||||
'builtInCategory': 'Yerleşik kategori silinemez',
|
'builtInCategory': 'Yerleşik kategori silinemez',
|
||||||
'selectCategory': 'Kategori Seç',
|
'selectCategory': 'Kategori Seç',
|
||||||
|
'githubMirror': 'GitHub Yansısı',
|
||||||
|
'githubMirrorEnable': 'GitHub Yansısını Etkinleştir',
|
||||||
|
'githubMirrorDesc': 'GitHub içerik indirmelerini hızlandırmak için yansıları kullanın',
|
||||||
|
'githubMirrorType': 'Yansı Türü',
|
||||||
|
'githubMirrorUrl': 'Yansı URL\'si',
|
||||||
|
'githubMirrorUrlHint': 'Yansı URL\'sini girin',
|
||||||
|
'updateUrl': 'Güncelleme URL\'si',
|
||||||
|
'updateUrlManual': 'Manuel Güncelleme',
|
||||||
|
'updateUrlListen': 'Güncellemeyi İzle',
|
||||||
|
'updateUrlListeningTip': 'URL güncellemesi bekleniyor',
|
||||||
|
'updateUrlCancelListen': 'İzlemeyi İptal Et',
|
||||||
|
'updateUrlDialogHint': 'Yeni indirme URL\'sini girin',
|
||||||
|
'pendingUpdateFound': 'Bekleyen Güncelleme İşlemi Bulundu',
|
||||||
|
'pendingUpdateConfirm':
|
||||||
|
'"@name" işlemi URL güncellemesi bekliyor. Yeni URL ile güncellemek istiyor musunuz?',
|
||||||
|
'pendingUpdateYes': 'İşlemi Güncelle',
|
||||||
|
'pendingUpdateNo': 'Yeni İşlem Oluştur',
|
||||||
|
'desktopNotification': 'Masaüstü Bildirimleri',
|
||||||
|
'notificationTaskDone': 'Görev tamamlandı',
|
||||||
|
'notificationTaskError': 'Görev hatası',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -152,5 +152,8 @@ const ukUA = {
|
|||||||
'categoryPath': 'Шлях категорії',
|
'categoryPath': 'Шлях категорії',
|
||||||
'builtInCategory': 'Вбудовану категорію неможливо видалити',
|
'builtInCategory': 'Вбудовану категорію неможливо видалити',
|
||||||
'selectCategory': 'Вибрати категорію',
|
'selectCategory': 'Вибрати категорію',
|
||||||
|
'desktopNotification': 'Сповіщення',
|
||||||
|
'notificationTaskDone': 'Завдання завершено',
|
||||||
|
'notificationTaskError': 'Помилка завдання',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -122,5 +122,8 @@ const viVN = {
|
|||||||
'launchAtStartup': 'Khởi động cùng hệ thống',
|
'launchAtStartup': 'Khởi động cùng hệ thống',
|
||||||
'runAsMenubarApp': 'Chạy như ứng dụng thanh menu',
|
'runAsMenubarApp': 'Chạy như ứng dụng thanh menu',
|
||||||
'runAsMenubarAppDesc': 'Ẩn biểu tượng Dock và chỉ chạy trong thanh menu',
|
'runAsMenubarAppDesc': 'Ẩn biểu tượng Dock và chỉ chạy trong thanh menu',
|
||||||
|
'desktopNotification': 'Thông báo trên màn hình',
|
||||||
|
'notificationTaskDone': 'Nhiệm vụ hoàn thành',
|
||||||
|
'notificationTaskError': 'Lỗi nhiệm vụ',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -78,6 +78,10 @@ const zhCN = {
|
|||||||
'webhookTest': '测试',
|
'webhookTest': '测试',
|
||||||
'webhookTestSuccess': 'Webhook 测试成功',
|
'webhookTestSuccess': 'Webhook 测试成功',
|
||||||
'webhookTestFail': 'Webhook 测试失败',
|
'webhookTestFail': 'Webhook 测试失败',
|
||||||
|
'script': '脚本执行',
|
||||||
|
'scriptEnable': '启用脚本',
|
||||||
|
'scriptDesc': '在下载成功完成时执行自定义脚本',
|
||||||
|
'scriptPathHint': '请输入脚本文件路径(例如:/path/to/script.sh)',
|
||||||
'urlInvalid': '请输入有效的 HTTP 或 HTTPS 链接',
|
'urlInvalid': '请输入有效的 HTTP 或 HTTPS 链接',
|
||||||
'required': '此项为必填项',
|
'required': '此项为必填项',
|
||||||
'show': '显示',
|
'show': '显示',
|
||||||
@@ -101,6 +105,27 @@ const zhCN = {
|
|||||||
'extensionAlreadyLatest': '已经是最新版本',
|
'extensionAlreadyLatest': '已经是最新版本',
|
||||||
'extensionFind': '获取扩展',
|
'extensionFind': '获取扩展',
|
||||||
'extensionDevelop': '开发扩展',
|
'extensionDevelop': '开发扩展',
|
||||||
|
'extensionStoreTitle': '扩展商店',
|
||||||
|
'extensionStoreSection': '在线扩展',
|
||||||
|
'extensionInstalledSection': '已安装扩展',
|
||||||
|
'extensionInstalledEmpty': '暂未安装扩展',
|
||||||
|
'extensionStoreEmpty': '没有匹配的扩展',
|
||||||
|
'extensionSearchHint': '按名称、标题、描述或作者搜索',
|
||||||
|
'extensionSortStars': '最多 Star',
|
||||||
|
'extensionSortInstalls': '最多安装',
|
||||||
|
'extensionSortUpdated': '最近更新',
|
||||||
|
'extensionSortToggle': '切换排序方向',
|
||||||
|
'extensionLoadMore': '加载更多',
|
||||||
|
'extensionNoMore': '已经到底了',
|
||||||
|
'extensionInstalled': '已安装',
|
||||||
|
'extensionCanUpdate': '可更新',
|
||||||
|
'extensionInstall': '安装',
|
||||||
|
'extensionLoadLocal': '安装本地目录',
|
||||||
|
'extensionFilterAll': '全部',
|
||||||
|
'extensionFilterMarket': '市场',
|
||||||
|
'extensionFilterInstalled': '已安装',
|
||||||
|
'extensionManualInstall': '手动安装',
|
||||||
|
'extensionInstallTools': '安装方式',
|
||||||
'history': '历史记录',
|
'history': '历史记录',
|
||||||
'clearHistory': '清空历史记录',
|
'clearHistory': '清空历史记录',
|
||||||
'noHistoryFound': '暂无历史记录',
|
'noHistoryFound': '暂无历史记录',
|
||||||
@@ -144,6 +169,7 @@ const zhCN = {
|
|||||||
'unknown': '未知',
|
'unknown': '未知',
|
||||||
'fileSelectedCount': '文件数:',
|
'fileSelectedCount': '文件数:',
|
||||||
'fileSelectedSize': '大小:',
|
'fileSelectedSize': '大小:',
|
||||||
|
'httpHeader': '请求头',
|
||||||
'httpHeaderName': '请求头名称',
|
'httpHeaderName': '请求头名称',
|
||||||
'httpHeaderValue': '请求头值',
|
'httpHeaderValue': '请求头值',
|
||||||
'login': '登录',
|
'login': '登录',
|
||||||
@@ -173,5 +199,18 @@ const zhCN = {
|
|||||||
'githubMirrorType': '镜像类型',
|
'githubMirrorType': '镜像类型',
|
||||||
'githubMirrorUrl': '镜像地址',
|
'githubMirrorUrl': '镜像地址',
|
||||||
'githubMirrorUrlHint': '请输入镜像地址',
|
'githubMirrorUrlHint': '请输入镜像地址',
|
||||||
|
'updateUrl': '更新地址',
|
||||||
|
'updateUrlManual': '手动更新',
|
||||||
|
'updateUrlListen': '监听更新',
|
||||||
|
'updateUrlListeningTip': '正在等待更新地址',
|
||||||
|
'updateUrlCancelListen': '取消监听',
|
||||||
|
'updateUrlDialogHint': '请输入新的下载地址',
|
||||||
|
'pendingUpdateFound': '发现待更新任务',
|
||||||
|
'pendingUpdateConfirm': '任务 "@name" 正在等待更新地址,是否使用新地址更新该任务?',
|
||||||
|
'pendingUpdateYes': '更新任务',
|
||||||
|
'pendingUpdateNo': '创建新任务',
|
||||||
|
'desktopNotification': '桌面通知',
|
||||||
|
'notificationTaskDone': '任务完成',
|
||||||
|
'notificationTaskError': '任务失败',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -147,5 +147,8 @@ const zhTW = {
|
|||||||
'categoryPath': '分類路徑',
|
'categoryPath': '分類路徑',
|
||||||
'builtInCategory': '內建分類無法刪除',
|
'builtInCategory': '內建分類無法刪除',
|
||||||
'selectCategory': '選擇分類',
|
'selectCategory': '選擇分類',
|
||||||
|
'desktopNotification': '桌面通知',
|
||||||
|
'notificationTaskDone': '任務完成',
|
||||||
|
'notificationTaskError': '任務失敗',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
+22
-35
@@ -22,37 +22,32 @@ import 'util/scheme_register/scheme_register.dart';
|
|||||||
import 'util/updater.dart';
|
import 'util/updater.dart';
|
||||||
import 'util/util.dart';
|
import 'util/util.dart';
|
||||||
|
|
||||||
class Args {
|
class StartupArgs {
|
||||||
static const flagHidden = "hidden";
|
static const flagHidden = "hidden";
|
||||||
|
|
||||||
bool hidden = false;
|
/// Command line --hidden flag (for auto-start)
|
||||||
|
bool hiddenFromArgs = false;
|
||||||
|
|
||||||
Args();
|
StartupArgs._();
|
||||||
|
|
||||||
Args.parse(List<String> args) {
|
/// Parse from command line arguments only
|
||||||
final parser = ArgParser();
|
static StartupArgs parse(List<String> arguments) {
|
||||||
parser.addFlag(flagHidden);
|
final args = StartupArgs._();
|
||||||
final results = parser.parse(args);
|
try {
|
||||||
hidden = results.flag(flagHidden);
|
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 {
|
void main(List<String> arguments) async {
|
||||||
Args args;
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
// Parse url scheme arguments, e.g. gopeed:?hidden=true
|
|
||||||
// TODO: macos open url handle
|
final args = StartupArgs.parse(arguments);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
await init(args);
|
await init(args);
|
||||||
onStart();
|
onStart();
|
||||||
@@ -60,8 +55,8 @@ void main(List<String> arguments) async {
|
|||||||
runApp(const AppView());
|
runApp(const AppView());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> init(Args args) async {
|
Future<void> init(StartupArgs args) async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
// Note: WidgetsFlutterBinding.ensureInitialized() is already called in main()
|
||||||
if (Util.isMobile()) {
|
if (Util.isMobile()) {
|
||||||
FlutterForegroundTask.initCommunicationPort();
|
FlutterForegroundTask.initCommunicationPort();
|
||||||
}
|
}
|
||||||
@@ -81,16 +76,7 @@ Future<void> init(Args args) async {
|
|||||||
skipTaskbar: runAsMenubarApp,
|
skipTaskbar: runAsMenubarApp,
|
||||||
);
|
);
|
||||||
await windowManager.waitUntilReadyToShow(windowOptions, () async {
|
await windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||||
if (!args.hidden) {
|
|
||||||
await windowManager.show();
|
|
||||||
await windowManager.focus();
|
|
||||||
}
|
|
||||||
await windowManager.setPreventClose(true);
|
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
|
// 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);
|
logger.e("init package info fail", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
final controller = Get.put(AppController());
|
final controller =
|
||||||
|
Get.put(AppController(hiddenFromArgs: args.hiddenFromArgs));
|
||||||
try {
|
try {
|
||||||
await controller.loadStartConfig();
|
await controller.loadStartConfig();
|
||||||
final startCfg = controller.startConfig.value;
|
final startCfg = controller.startConfig.value;
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ const _hostName = 'com.gopeed.gopeed';
|
|||||||
const _chromeExtensionId = 'mijpgljlfcapndmchhjffkpckknofcnd';
|
const _chromeExtensionId = 'mijpgljlfcapndmchhjffkpckknofcnd';
|
||||||
const _edgeExtensionId = 'dkajnckekendchdleoaenoophcobooce';
|
const _edgeExtensionId = 'dkajnckekendchdleoaenoophcobooce';
|
||||||
const _firefoxExtensionId = '{c5d69a8f-2ed0-46a7-afa4-b3a00dc58088}';
|
const _firefoxExtensionId = '{c5d69a8f-2ed0-46a7-afa4-b3a00dc58088}';
|
||||||
const _debugExtensionIds = [
|
|
||||||
'gjddllnejledbfaeondocjpejpamclkk',
|
List<String> get _debugExtensionIds {
|
||||||
'goaohdfiokcjapgonhofgljfccoccief'
|
final envValue = Platform.environment['GOPEED_DEBUG_EXTENSION_IDS'] ?? '';
|
||||||
];
|
if (envValue.isNotEmpty) {
|
||||||
|
return envValue.split(',').map((id) => id.trim()).toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// Windows NativeMessagingHosts registry constants
|
// Windows NativeMessagingHosts registry constants
|
||||||
const _chromeNativeHostsKey = r'Software\Google\Chrome\NativeMessagingHosts';
|
const _chromeNativeHostsKey = r'Software\Google\Chrome\NativeMessagingHosts';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../api/api.dart';
|
import '../api/api.dart';
|
||||||
|
import '../api/gopeed_site_api.dart';
|
||||||
import '../app/views/outlined_button_loading.dart';
|
import '../app/views/outlined_button_loading.dart';
|
||||||
import 'arch/arch.dart';
|
import 'arch/arch.dart';
|
||||||
import 'github_mirror.dart';
|
import 'github_mirror.dart';
|
||||||
@@ -70,8 +71,7 @@ Future<VersionInfo?> checkUpdate() async {
|
|||||||
"https://api.github.com/repos/GopeedLab/gopeed/releases/latest"))
|
"https://api.github.com/repos/GopeedLab/gopeed/releases/latest"))
|
||||||
.data;
|
.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
releaseDataStr =
|
releaseDataStr = jsonEncode(await GopeedSiteApi.instance.getRelease());
|
||||||
(await proxyRequest("https://gopeed.com/api/release")).data;
|
|
||||||
}
|
}
|
||||||
if (releaseDataStr == null) {
|
if (releaseDataStr == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+80
-72
@@ -9,14 +9,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "67.0.0"
|
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:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -25,14 +17,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.4.1"
|
version: "6.4.1"
|
||||||
animations:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: animations
|
|
||||||
sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.11"
|
|
||||||
app_links:
|
app_links:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -173,10 +157,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
checkable_treeview:
|
checkable_treeview:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -233,22 +217,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.0.2"
|
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:
|
convert:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -297,6 +265,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.6"
|
version: "2.3.6"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.12"
|
||||||
desktop_drop:
|
desktop_drop:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -393,19 +369,19 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
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:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_foreground_task:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -438,11 +414,51 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.3"
|
version: "2.0.3"
|
||||||
|
flutter_local_notifications:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications
|
||||||
|
sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "20.1.0"
|
||||||
|
flutter_local_notifications_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_linux
|
||||||
|
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.0"
|
||||||
|
flutter_local_notifications_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_platform_interface
|
||||||
|
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.0.0"
|
||||||
|
flutter_local_notifications_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_windows
|
||||||
|
sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.1"
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_markdown_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_markdown_plus
|
||||||
|
sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.7"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -718,30 +734,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
version: "1.3.0"
|
||||||
|
markdown:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: markdown
|
||||||
|
sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.3.0"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.18"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
math_expressions:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: math_expressions
|
|
||||||
sha256: e32d803d758ace61cc6c4bdfed1226ff60a6a23646b35685670d28b5616139f8
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.6.0"
|
|
||||||
menu_base:
|
menu_base:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1007,14 +1023,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.2"
|
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:
|
rounded_loading_button_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1039,14 +1047,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.9"
|
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:
|
share_handler:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1264,10 +1264,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.9"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.10.1"
|
||||||
timing:
|
timing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
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.
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||||
# Read more about iOS versioning at
|
# Read more about iOS versioning at
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
version: 1.9.0+1
|
version: 1.9.2+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.0.0 <4.0.0"
|
sdk: ">=3.0.0 <4.0.0"
|
||||||
@@ -37,7 +37,7 @@ dependencies:
|
|||||||
cupertino_icons: ^1.0.2
|
cupertino_icons: ^1.0.2
|
||||||
get: ^4.6.6
|
get: ^4.6.6
|
||||||
styled_widget: ^0.4.0+3
|
styled_widget: ^0.4.0+3
|
||||||
context_menus: ^1.2.0
|
flutter_context_menu: ^0.4.1
|
||||||
json_annotation: ^4.8.1
|
json_annotation: ^4.8.1
|
||||||
dio: ^5.2.1
|
dio: ^5.2.1
|
||||||
path_provider: ^2.1.4
|
path_provider: ^2.1.4
|
||||||
@@ -47,7 +47,6 @@ dependencies:
|
|||||||
logger: ^1.4.0
|
logger: ^1.4.0
|
||||||
desktop_drop: ^0.4.4
|
desktop_drop: ^0.4.4
|
||||||
package_info_plus: ^8.1.0
|
package_info_plus: ^8.1.0
|
||||||
fluent_ui: ^4.9.2
|
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
badges: ^3.0.3
|
badges: ^3.0.3
|
||||||
app_links: ^6.3.3
|
app_links: ^6.3.3
|
||||||
@@ -72,7 +71,6 @@ dependencies:
|
|||||||
permission_handler: ^11.3.1
|
permission_handler: ^11.3.1
|
||||||
device_info_plus: ^11.1.0
|
device_info_plus: ^11.1.0
|
||||||
checkable_treeview: ^1.3.1
|
checkable_treeview: ^1.3.1
|
||||||
contextmenu_plus: ^1.0.1
|
|
||||||
contentsize_tabbarview: ^0.0.2
|
contentsize_tabbarview: ^0.0.2
|
||||||
win32_registry: ^1.1.5
|
win32_registry: ^1.1.5
|
||||||
share_handler: ^0.0.22
|
share_handler: ^0.0.22
|
||||||
@@ -83,7 +81,9 @@ dependencies:
|
|||||||
url: https://github.com/hui-z/flutter_install_plugin.git
|
url: https://github.com/hui-z/flutter_install_plugin.git
|
||||||
ref: cf08af829f4a4145634f8a047108f505fdbe5eaa
|
ref: cf08af829f4a4145634f8a047108f505fdbe5eaa
|
||||||
flutter_svg: ^2.0.17
|
flutter_svg: ^2.0.17
|
||||||
|
flutter_markdown_plus: ^1.0.3
|
||||||
dart_ipc: ^1.0.1
|
dart_ipc: ^1.0.1
|
||||||
|
flutter_local_notifications: 20.1.0
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
permission_handler_windows:
|
permission_handler_windows:
|
||||||
git:
|
git:
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
|||||||
|
|
||||||
flutter::DartProject project(L"data");
|
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 =
|
std::vector<std::string> command_line_arguments =
|
||||||
GetCommandLineArguments();
|
GetCommandLineArguments();
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user