mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-14 12:23:43 +00:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfda439b82 | ||
|
|
c2ada7dbaa | ||
|
|
6f0cfdcd91 | ||
|
|
fdfc6902a7 | ||
|
|
9fb3758203 | ||
|
|
e2ac8ce64b | ||
|
|
c01d11305c | ||
|
|
def54f8998 | ||
|
|
a205c0b8d7 | ||
|
|
ae70dc240c | ||
|
|
d7f6c3b206 | ||
|
|
a83e38dac4 | ||
|
|
d46c06f08d | ||
|
|
a61cdbed67 | ||
|
|
daa8c39dad | ||
|
|
a5cd53f94c | ||
|
|
847e88881d | ||
|
|
3d20e818e5 | ||
|
|
09f7c88ed3 | ||
|
|
f7b26753c1 | ||
|
|
9704d4af27 | ||
|
|
1907f404ac | ||
|
|
72c5ba7d20 | ||
|
|
5b0254f7c0 | ||
|
|
d36b345f92 | ||
|
|
bc5c818eae | ||
|
|
98bd3305b7 | ||
|
|
3f38848051 | ||
|
|
82d7f8ec81 | ||
|
|
a6981d58e9 | ||
|
|
1b1631b41a | ||
|
|
3eb6df55df | ||
|
|
6de63c3f63 | ||
|
|
969615f353 | ||
|
|
5a88b98314 | ||
|
|
2674b1c39e | ||
|
|
4c90b38247 | ||
|
|
93088b22f1 | ||
|
|
fe90c1d261 |
No files matched your search
+1
-1
@@ -12,4 +12,4 @@
|
|||||||
#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
# github: monkeyWie
|
# github: monkeyWie
|
||||||
ko_fi: gopeed
|
ko_fi: gopeed
|
||||||
custom: https://docs.gopeed.com/donate.html
|
custom: https://gopeed.com/docs/donate
|
||||||
+22
-24
@@ -13,8 +13,8 @@ on:
|
|||||||
default: "false"
|
default: "false"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.24"
|
GO_VERSION: "1.25"
|
||||||
FLUTTER_VERSION: "3.38.7"
|
FLUTTER_VERSION: "3.41.6"
|
||||||
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)
|
||||||
@@ -79,12 +81,12 @@ jobs:
|
|||||||
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
||||||
$b >> $env:GITHUB_PATH
|
$b >> $env:GITHUB_PATH
|
||||||
|
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
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
|
||||||
@@ -184,7 +187,7 @@ jobs:
|
|||||||
mv "Output\gopeed.exe" "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe"
|
mv "Output\gopeed.exe" "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe"
|
||||||
Compress-Archive -Path "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe" -DestinationPath "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.zip"
|
Compress-Archive -Path "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe" -DestinationPath "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.zip"
|
||||||
- name: Upload
|
- name: Upload
|
||||||
uses: shogo82148/actions-upload-release-asset@v1
|
uses: shogo82148/actions-upload-release-asset@v1.9.2
|
||||||
with:
|
with:
|
||||||
upload_url: ${{ needs.get-release.outputs.upload_url }}
|
upload_url: ${{ needs.get-release.outputs.upload_url }}
|
||||||
asset_path: ui/flutter/build/windows/Output/*
|
asset_path: ui/flutter/build/windows/Output/*
|
||||||
@@ -195,7 +198,7 @@ jobs:
|
|||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- name: Build
|
- name: Build
|
||||||
@@ -221,7 +224,7 @@ jobs:
|
|||||||
needs: [get-release, build-macos-amd64-lib]
|
needs: [get-release, build-macos-amd64-lib]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
@@ -312,7 +315,7 @@ jobs:
|
|||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
@@ -321,7 +324,7 @@ jobs:
|
|||||||
channel: master
|
channel: master
|
||||||
- run: |
|
- run: |
|
||||||
sudo apt update -y
|
sudo apt update -y
|
||||||
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate imagemagick potrace libkeybinder-3.0-dev
|
sudo apt install -y ninja-build libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate libkeybinder-3.0-dev
|
||||||
arch=$(uname -m)
|
arch=$(uname -m)
|
||||||
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
|
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
|
||||||
chmod +x appimagetool
|
chmod +x appimagetool
|
||||||
@@ -336,18 +339,11 @@ jobs:
|
|||||||
|
|
||||||
cd ui/flutter
|
cd ui/flutter
|
||||||
|
|
||||||
ICON_PATH=$(find assets -name "logo.png" -o -name "icon.png" | head -n 1)
|
|
||||||
[ -z "$ICON_PATH" ] && ICON_PATH="assets/icon.png" && touch assets/icon.png
|
|
||||||
|
|
||||||
convert "$ICON_PATH" assets/icon.svg
|
|
||||||
ICON_PATH="assets/icon.svg"
|
|
||||||
|
|
||||||
RPM_ARCH=$(uname -m)
|
RPM_ARCH=$(uname -m)
|
||||||
|
|
||||||
mkdir -p linux/packaging/rpm
|
mkdir -p linux/packaging/rpm
|
||||||
cat << EOF > linux/packaging/rpm/make_config.yaml
|
cat << EOF > linux/packaging/rpm/make_config.yaml
|
||||||
display_name: Gopeed
|
display_name: Gopeed
|
||||||
icon: $ICON_PATH
|
icon: assets/icon/icon.svg
|
||||||
summary: High speed downloader.
|
summary: High speed downloader.
|
||||||
description: A high speed downloader that supports all platforms.
|
description: A high speed downloader that supports all platforms.
|
||||||
group: Applications/Internet
|
group: Applications/Internet
|
||||||
@@ -415,7 +411,7 @@ jobs:
|
|||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- name: Setup LXD
|
- name: Setup LXD
|
||||||
@@ -484,6 +480,7 @@ jobs:
|
|||||||
- cmake
|
- cmake
|
||||||
- curl
|
- curl
|
||||||
- libgtk-3-dev
|
- libgtk-3-dev
|
||||||
|
- libwebkit2gtk-4.1-dev
|
||||||
- libkeybinder-3.0-dev
|
- libkeybinder-3.0-dev
|
||||||
- ninja-build
|
- ninja-build
|
||||||
- unzip
|
- unzip
|
||||||
@@ -499,6 +496,7 @@ jobs:
|
|||||||
source: .
|
source: .
|
||||||
plugin: nil
|
plugin: nil
|
||||||
stage-packages:
|
stage-packages:
|
||||||
|
- libwebkit2gtk-4.1-0
|
||||||
- libgtk-3-dev
|
- libgtk-3-dev
|
||||||
- libkeybinder-3.0-dev
|
- libkeybinder-3.0-dev
|
||||||
- libappindicator3-dev
|
- libappindicator3-dev
|
||||||
@@ -549,7 +547,7 @@ jobs:
|
|||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: actions/setup-java@v3
|
- uses: actions/setup-java@v3
|
||||||
@@ -591,7 +589,7 @@ jobs:
|
|||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
@@ -624,7 +622,7 @@ jobs:
|
|||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ on:
|
|||||||
- "Dockerfile"
|
- "Dockerfile"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.24"
|
GO_VERSION: "1.25"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: release-drafter/release-drafter@v5
|
- uses: release-drafter/release-drafter@v5
|
||||||
|
|||||||
+106
-15
@@ -28,8 +28,8 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.24"
|
GO_VERSION: "1.25"
|
||||||
FLUTTER_VERSION: "3.38.7"
|
FLUTTER_VERSION: "3.41.6"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# lint:
|
# lint:
|
||||||
@@ -50,14 +50,18 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
- name: Install webview dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -y
|
||||||
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev xvfb
|
||||||
- name: Run Unit tests.
|
- name: Run Unit tests.
|
||||||
run: |
|
run: |
|
||||||
# Get packages for testing and coverage (including cmd/web for flags_test.go)
|
# Get packages for testing and coverage (including cmd/web for flags_test.go)
|
||||||
PACKAGES=$(go list ./... | grep -v /bind/ | grep -v -E '/cmd/(?!web)')
|
PACKAGES=$(go list ./... | awk '!/\/bind\// && (!/\/cmd\// || /\/cmd\/web$/)')
|
||||||
go test -v -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $PACKAGES
|
env CGO_ENABLED=1 xvfb-run -a go test -tags webview -v -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $PACKAGES
|
||||||
# Filter out main.go from coverage report to avoid lowering coverage percentage
|
# Filter out main.go from coverage report to avoid lowering coverage percentage
|
||||||
grep -v "main.go" coverage.txt > coverage_filtered.txt || true
|
grep -v "main.go" coverage.txt > coverage_filtered.txt || true
|
||||||
mv coverage_filtered.txt coverage.txt
|
mv coverage_filtered.txt coverage.txt
|
||||||
@@ -65,22 +69,109 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
files: ./coverage.txt
|
files: ./coverage.txt
|
||||||
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
||||||
build-desktop:
|
test-webview-integration:
|
||||||
name: Build desktop check
|
name: Test webview integration (${{ matrix.name }})
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: Windows
|
||||||
|
os: windows-latest
|
||||||
|
- name: macOS
|
||||||
|
os: macos-latest
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
- name: Test webview
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
run: go test -tags webview -v -timeout 300s ./internal/webview/goprovider/
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: 1
|
||||||
|
- name: Test webview
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SOCKET_PATH="$HOME/Library/Application Support/com.gopeed.gopeed/gopeed_webview.sock"
|
||||||
|
LOG_PATH="$RUNNER_TEMP/gopeed-webview-rpc.log"
|
||||||
|
rm -f "$SOCKET_PATH"
|
||||||
|
|
||||||
|
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
|
||||||
|
|
||||||
|
cd ui/flutter
|
||||||
|
flutter pub get
|
||||||
|
flutter run -d macos >"$LOG_PATH" 2>&1 &
|
||||||
|
FLUTTER_PID=$!
|
||||||
|
cd ../..
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
kill "$FLUTTER_PID" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
if ! go test -tags webview -v -timeout 600s ./internal/webview/rpcprovider/ -args -webview-rpc-network unix -webview-rpc-address "$SOCKET_PATH"; then
|
||||||
|
cat "$LOG_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-desktop:
|
||||||
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
|
||||||
- uses: actions/setup-go@v4
|
- 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@v5
|
||||||
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
|
||||||
@@ -94,7 +185,7 @@ jobs:
|
|||||||
- if: runner.os == 'Linux'
|
- if: runner.os == 'Linux'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update -y
|
sudo apt-get update -y
|
||||||
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libkeybinder-3.0-dev
|
sudo apt-get install -y ninja-build libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
|
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
|
||||||
cd ui/flutter
|
cd ui/flutter
|
||||||
flutter build linux
|
flutter build linux
|
||||||
@@ -107,7 +198,7 @@ jobs:
|
|||||||
needs: [ test ]
|
needs: [ test ]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
@@ -139,7 +230,7 @@ jobs:
|
|||||||
needs: [ test ]
|
needs: [ test ]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
@@ -160,7 +251,7 @@ jobs:
|
|||||||
needs: [ test ]
|
needs: [ test ]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.24.11 AS go
|
FROM golang:1.25.4-alpine3.22 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,10 +1,10 @@
|
|||||||
# [](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)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://docs.gopeed.com/donate.html)
|
[](https://gopeed.com/docs/donate)
|
||||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||||
[](https://discord.gg/ZUJqJrwCGB)
|
[](https://discord.gg/ZUJqJrwCGB)
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
## 🚀 Introduction
|
## 🚀 Introduction
|
||||||
|
|
||||||
Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet) protocol, and supports all platforms. In addition to basic download functions, Gopeed is also a highly customizable downloader that supports implementing more features through integration with [APIs](https://docs.gopeed.com/dev-api.html) or installation and development of [extensions](https://docs.gopeed.com/dev-extension.html).
|
Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet, ED2K) protocol, and supports all platforms. In addition to basic download functions, Gopeed is also a highly customizable downloader that supports implementing more features through integration with [APIs](https://gopeed.com/docs/dev-api) or installation and development of [extensions](https://gopeed.com/docs/dev-extension).
|
||||||
|
|
||||||
Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://docs.gopeed.com)
|
Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://gopeed.com/docs)
|
||||||
|
|
||||||
## ⬇️ Download
|
## ⬇️ Download
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
More about installation, please refer to [Installation](https://docs.gopeed.com/install.html)
|
More about installation, please refer to [Installation](https://gopeed.com/docs/install)
|
||||||
|
|
||||||
### 🛠️ Command tool
|
### 🛠️ Command tool
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ Follow our WeChat Official Account to get the latest updates and news.
|
|||||||
|
|
||||||
## 💝 Donate
|
## 💝 Donate
|
||||||
|
|
||||||
If you like this project, please consider [donating](https://docs.gopeed.com/donate.html) to support the development of this project, thank you!
|
If you like this project, please consider [donating](https://gopeed.com/docs/donate) to support the development of this project, thank you!
|
||||||
|
|
||||||
## 🖼️ Showcase
|
## 🖼️ Showcase
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ This project is divided into two parts, the front end uses `flutter`, the back e
|
|||||||
|
|
||||||
### 🌍 Environment
|
### 🌍 Environment
|
||||||
|
|
||||||
1. Golang 1.24+
|
1. Golang 1.25+
|
||||||
2. Flutter 3.38+
|
2. Flutter 3.38+
|
||||||
|
|
||||||
### 📋 Clone
|
### 📋 Clone
|
||||||
|
|||||||
+7
-7
@@ -1,10 +1,10 @@
|
|||||||
# [](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)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://docs.gopeed.com/donate.html)
|
[](https://gopeed.com/docs/donate)
|
||||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||||
[](https://discord.gg/ZUJqJrwCGB)
|
[](https://discord.gg/ZUJqJrwCGB)
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
## 🚀 はじめに
|
## 🚀 はじめに
|
||||||
|
|
||||||
Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet) プロトコルをサポートし、すべてのプラットフォームをサポートします。基本的なダウンロード機能に加え、[APIs](https://docs.gopeed.com/dev-api.html)との連動や[拡張機能](https://docs.gopeed.com/dev-extension.html)のインストール・開発による追加機能にも対応した、カスタマイズ性の高いダウンローダーです。
|
Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet、ED2K) プロトコルをサポートし、すべてのプラットフォームをサポートします。基本的なダウンロード機能に加え、[APIs](https://gopeed.com/docs/dev-api)との連動や[拡張機能](https://gopeed.com/docs/dev-extension)のインストール・開発による追加機能にも対応した、カスタマイズ性の高いダウンローダーです。
|
||||||
|
|
||||||
見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://docs.gopeed.com)
|
見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://gopeed.com/docs)
|
||||||
|
|
||||||
## ⬇️ インストール
|
## ⬇️ インストール
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
インストールについての詳細は、[インストール](https://docs.gopeed.com/install.html)を参照してください。
|
インストールについての詳細は、[インストール](https://gopeed.com/docs/install)を参照してください。
|
||||||
|
|
||||||
### 🛠️ コマンドツール
|
### 🛠️ コマンドツール
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
|||||||
|
|
||||||
## 💝 寄付
|
## 💝 寄付
|
||||||
|
|
||||||
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://docs.gopeed.com/donate.html)をご検討ください!
|
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://gopeed.com/docs/donate)をご検討ください!
|
||||||
|
|
||||||
## 🖼️ ショーケース
|
## 🖼️ ショーケース
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
|||||||
|
|
||||||
### 🌍 環境
|
### 🌍 環境
|
||||||
|
|
||||||
1. Go 言語 1.24+
|
1. Go 言語 1.25+
|
||||||
2. Flutter 3.38+
|
2. Flutter 3.38+
|
||||||
|
|
||||||
### 📋 クローン
|
### 📋 クローン
|
||||||
|
|||||||
+7
-7
@@ -1,10 +1,10 @@
|
|||||||
# [](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)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://docs.gopeed.com/donate.html)
|
[](https://gopeed.com/docs/donate)
|
||||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||||
[](https://discord.gg/ZUJqJrwCGB)
|
[](https://discord.gg/ZUJqJrwCGB)
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
## 🚀 Giới thiệu
|
## 🚀 Giới thiệu
|
||||||
|
|
||||||
Gopeed (tên đầy đủ Go Speed), một công cụ tải xuống tốc độ cao được phát triển bởi `Golang` + `Flutter`, hỗ trợ giao thức (HTTP, BitTorrent, Magnet) và hỗ trợ tất cả các nền tảng. Ngoài các chức năng tải xuống cơ bản, Gopeed còn là một công cụ tải xuống có thể tùy chỉnh cao cho phép triển khai thêm tính năng thông qua việc tích hợp với [APIs](https://docs.gopeed.com/dev-api.html) hoặc cài đặt và phát triển các [tiện ích mở rộng](https://docs.gopeed.com/dev-extension.html).
|
Gopeed (tên đầy đủ Go Speed), một công cụ tải xuống tốc độ cao được phát triển bởi `Golang` + `Flutter`, hỗ trợ giao thức (HTTP, BitTorrent, Magnet, ED2K) và hỗ trợ tất cả các nền tảng. Ngoài các chức năng tải xuống cơ bản, Gopeed còn là một công cụ tải xuống có thể tùy chỉnh cao cho phép triển khai thêm tính năng thông qua việc tích hợp với [APIs](https://gopeed.com/docs/dev-api) hoặc cài đặt và phát triển các [tiện ích mở rộng](https://gopeed.com/docs/dev-extension).
|
||||||
|
|
||||||
Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu chính thức](https://docs.gopeed.com)
|
Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu chính thức](https://gopeed.com/docs)
|
||||||
|
|
||||||
## ⬇️ Tải về
|
## ⬇️ Tải về
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
Thêm thông tin về cài đặt, vui lòng tham khảo [Cài đặt](https://docs.gopeed.com/install.html)
|
Thêm thông tin về cài đặt, vui lòng tham khảo [Cài đặt](https://gopeed.com/docs/install)
|
||||||
|
|
||||||
### 🛠️ Công cụ lệnh
|
### 🛠️ Công cụ lệnh
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ Theo dõi tài khoản chính thức để nhận các cập nhật và tin tứ
|
|||||||
|
|
||||||
## 💝 Quyên góp
|
## 💝 Quyên góp
|
||||||
|
|
||||||
Nếu bạn thích dự án này, xin vui lòng xem xét [quyên góp](https://docs.gopeed.com/donate.html) để hỗ trợ phát triển dự án này, cảm ơn bạn!
|
Nếu bạn thích dự án này, xin vui lòng xem xét [quyên góp](https://gopeed.com/docs/donate) để hỗ trợ phát triển dự án này, cảm ơn bạn!
|
||||||
|
|
||||||
## 🖼️ Trưng bày
|
## 🖼️ Trưng bày
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ Dự án này được chia thành hai phần, phần giao diện sử dụng `f
|
|||||||
|
|
||||||
### 🌍 Environment
|
### 🌍 Environment
|
||||||
|
|
||||||
1. Golang 1.24+
|
1. Golang 1.25+
|
||||||
2. Flutter 3.38+
|
2. Flutter 3.38+
|
||||||
|
|
||||||
### 📋 Clone
|
### 📋 Clone
|
||||||
|
|||||||
+7
-7
@@ -1,10 +1,10 @@
|
|||||||
# [](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)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://docs.gopeed.com/zh/donate.html)
|
[](https://gopeed.com/docs/donate)
|
||||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||||
[](https://discord.gg/ZUJqJrwCGB)
|
[](https://discord.gg/ZUJqJrwCGB)
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
## 🚀 介绍
|
## 🚀 介绍
|
||||||
|
|
||||||
Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不是狗屁下载器!),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet)协议下载,并且支持全平台使用。除了基本的下载功能外,Gopeed 还是一款高度可定制化的下载器,支持通过对接[APIs](https://docs.gopeed.com/zh/dev-api.html)或者安装和开发[扩展](https://docs.gopeed.com/zh/dev-extension.html)来实现更多的功能。
|
Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不是狗屁下载器!),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet、ED2K)协议下载,并且支持全平台使用。除了基本的下载功能外,Gopeed 还是一款高度可定制化的下载器,支持通过对接[APIs](https://gopeed.com/docs/dev-api)或者安装和开发[扩展](https://gopeed.com/docs/dev-extension)来实现更多的功能。
|
||||||
|
|
||||||
访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [官方文档](https://docs.gopeed.com/zh/)
|
访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [官方文档](https://gopeed.com/docs)
|
||||||
|
|
||||||
## ⬇️ 下载
|
## ⬇️ 下载
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
更多关于安装的内容请参考[安装文档](https://docs.gopeed.com/zh/install.html)
|
更多关于安装的内容请参考[安装文档](https://gopeed.com/docs/install)
|
||||||
|
|
||||||
### 🛠️ 命令行工具
|
### 🛠️ 命令行工具
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、
|
|||||||
|
|
||||||
## 💝 赞助
|
## 💝 赞助
|
||||||
|
|
||||||
如果觉得项目对你有帮助,请考虑[赞助](https://docs.gopeed.com/zh/donate)以支持这个项目的发展,非常感谢!
|
如果觉得项目对你有帮助,请考虑[赞助](https://gopeed.com/docs/donate)以支持这个项目的发展,非常感谢!
|
||||||
|
|
||||||
## 🖼️ 界面展示
|
## 🖼️ 界面展示
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、
|
|||||||
|
|
||||||
### 🌍 环境要求
|
### 🌍 环境要求
|
||||||
|
|
||||||
1. Golang 1.24+
|
1. Golang 1.25+
|
||||||
2. Flutter 3.38+
|
2. Flutter 3.38+
|
||||||
|
|
||||||
### 📋 克隆项目
|
### 📋 克隆项目
|
||||||
|
|||||||
+7
-7
@@ -1,10 +1,10 @@
|
|||||||
# [](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)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://docs.gopeed.com/donate.html)
|
[](https://gopeed.com/docs/donate)
|
||||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||||
[](https://discord.gg/ZUJqJrwCGB)
|
[](https://discord.gg/ZUJqJrwCGB)
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
## 🚀 簡介
|
## 🚀 簡介
|
||||||
|
|
||||||
Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速下載軟體,支援(HTTP、BitTorrent、Magnet)協定,同時支援所有的平台。
|
Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速下載軟體,支援(HTTP、BitTorrent、Magnet、ED2K)協定,同時支援所有的平台。
|
||||||
|
|
||||||
前往 ✈ [主頁](https://gopeed.com/zh-CN) | 📖 [文檔](https://docs.gopeed.com/zh/)
|
前往 ✈ [主頁](https://gopeed.com/zh-CN) | 📖 [文檔](https://gopeed.com/docs)
|
||||||
|
|
||||||
## ⬇️ 下載
|
## ⬇️ 下載
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
更多關於安裝的內容請參考[安裝文檔](https://docs.gopeed.com/zh/install.html)
|
更多關於安裝的內容請參考[安裝文檔](https://gopeed.com/docs/install)
|
||||||
|
|
||||||
### 🛠️ 使用 CLI 安裝
|
### 🛠️ 使用 CLI 安裝
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
|||||||
|
|
||||||
## 💝 贊助
|
## 💝 贊助
|
||||||
|
|
||||||
如果你認為該項目對你有所幫助,請考慮[贊助](https://docs.gopeed.com/zh/donate)以支持該項目的持續發展,謝謝!
|
如果你認為該項目對你有所幫助,請考慮[贊助](https://gopeed.com/docs/donate)以支持該項目的持續發展,謝謝!
|
||||||
|
|
||||||
## 🖼️ 軟體介面
|
## 🖼️ 軟體介面
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
|||||||
|
|
||||||
### 🌍 開發環境
|
### 🌍 開發環境
|
||||||
|
|
||||||
1. Golang 1.24+
|
1. Golang 1.25+
|
||||||
2. Flutter 3.38+
|
2. Flutter 3.38+
|
||||||
|
|
||||||
### 📋 克隆項目
|
### 📋 克隆項目
|
||||||
|
|||||||
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 |
@@ -16,6 +16,7 @@ func Start(cfg *C.char) (int, *C.char) {
|
|||||||
return 0, C.CString(err.Error())
|
return 0, C.CString(err.Error())
|
||||||
}
|
}
|
||||||
config.ProductionMode = true
|
config.ProductionMode = true
|
||||||
|
applyWebViewProvider(&config)
|
||||||
realPort, err := rest.Start(&config)
|
realPort, err := rest.Start(&config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, C.CString(err.Error())
|
return 0, C.CString(err.Error())
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
||||||
|
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyWebViewProvider(config *model.StartConfig) {
|
||||||
|
if config == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config.WebViewProvider = newDesktopWebViewProvider(config.WebViewRPCConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDesktopWebViewProvider(rpcCfg *enginewebview.RPCConfig) enginewebview.Provider {
|
||||||
|
if rpcCfg != nil && rpcCfg.Enabled() {
|
||||||
|
return rpcprovider.New(*rpcCfg)
|
||||||
|
}
|
||||||
|
return enginewebview.NewUnavailableProvider()
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/GopeedLab/gopeed/internal/webview/goprovider"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyWebViewProvider(config *model.StartConfig) {
|
||||||
|
if config == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config.WebViewProvider = goprovider.New()
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ package libgopeed
|
|||||||
import "C"
|
import "C"
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest"
|
"github.com/GopeedLab/gopeed/pkg/rest"
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
)
|
)
|
||||||
@@ -14,9 +15,17 @@ func Start(cfg string) (int, error) {
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
config.ProductionMode = true
|
config.ProductionMode = true
|
||||||
|
applyWebViewProvider(&config)
|
||||||
return rest.Start(&config)
|
return rest.Start(&config)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Stop() {
|
func Stop() {
|
||||||
rest.Stop()
|
rest.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyWebViewProvider(config *model.StartConfig) {
|
||||||
|
if config == nil || config.WebViewRPCConfig == nil || !config.WebViewRPCConfig.Enabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config.WebViewProvider = rpcprovider.New(*config.WebViewRPCConfig)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,9 @@
|
|||||||
module github.com/GopeedLab/gopeed
|
module github.com/GopeedLab/gopeed
|
||||||
|
|
||||||
go 1.24.9
|
go 1.25.4
|
||||||
|
|
||||||
toolchain go1.24.11
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5
|
||||||
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
|
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||||
github.com/bodgit/sevenzip v1.6.1
|
github.com/bodgit/sevenzip v1.6.1
|
||||||
@@ -17,6 +16,7 @@ require (
|
|||||||
github.com/matoous/go-nanoid/v2 v2.0.0
|
github.com/matoous/go-nanoid/v2 v2.0.0
|
||||||
github.com/mattn/go-ieproxy v0.0.12
|
github.com/mattn/go-ieproxy v0.0.12
|
||||||
github.com/mholt/archives v0.1.5
|
github.com/mholt/archives v0.1.5
|
||||||
|
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
|
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
|
||||||
github.com/rs/zerolog v1.31.0
|
github.com/rs/zerolog v1.31.0
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmG
|
|||||||
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
|
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
|
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5 h1:86LVTaNDM/j/uTAD2HedkXNO9LIQlt642VAdbU/0D/M=
|
||||||
|
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
@@ -164,13 +166,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 +284,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=
|
||||||
@@ -392,6 +388,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447 h1:88kRsNwkDKPA4NzRUWdOoIL7SyMNDwtmAxzShKBvKTI=
|
||||||
|
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447/go.mod h1:Ry2y1QlzerUgA1hVmExBdXXzE4Sjk1M7w0nSh6dhDOg=
|
||||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||||
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
|
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
|
||||||
@@ -741,8 +739,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=
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
|
|
||||||
@@ -123,6 +127,21 @@ type FetcherManager interface {
|
|||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StatefulFetcherManager is an optional extension for protocols that keep
|
||||||
|
// shared client state outside individual task fetchers.
|
||||||
|
type StatefulFetcherManager interface {
|
||||||
|
SetStateStore(store ProtocolStateStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtocolStateStore persists shared protocol state for a fetcher manager.
|
||||||
|
// Downloader provides the concrete storage backend, while the protocol decides
|
||||||
|
// when state should be loaded or flushed.
|
||||||
|
type ProtocolStateStore interface {
|
||||||
|
Load(v any) (bool, error)
|
||||||
|
Save(v any) error
|
||||||
|
Delete() error
|
||||||
|
}
|
||||||
|
|
||||||
type DefaultFetcher struct {
|
type DefaultFetcher struct {
|
||||||
Ctl *controller.Controller
|
Ctl *controller.Controller
|
||||||
Meta *FetcherMeta
|
Meta *FetcherMeta
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package ed2k
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultServerList = "45.82.80.155:5687,176.123.5.89:4725,85.121.5.137:4232,176.123.2.239:4232,145.239.2.134:4661,91.208.162.87:4232,37.15.61.236:4232"
|
||||||
|
defaultServerMet = "ed2k://|serverlist|http://upd.emule-security.org/server.met|/"
|
||||||
|
defaultNodesDat = "https://upd.emule-security.org/nodes.dat"
|
||||||
|
)
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
ListenPort int `json:"listenPort"`
|
||||||
|
UDPPort int `json:"udpPort"`
|
||||||
|
ServerAddr string `json:"serverAddr"`
|
||||||
|
ServerMet string `json:"serverMet"`
|
||||||
|
NodesDat string `json:"nodesDat"`
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
|||||||
|
package ed2k
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/controller"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testLink = "ed2k://|file|cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso|4630972416|8867C5E54405FF9452225B66EFEE690A|/"
|
||||||
|
|
||||||
|
func TestFetcher_Resolve(t *testing.T) {
|
||||||
|
f := (&FetcherManager{}).Build()
|
||||||
|
f.Setup(controller.NewController())
|
||||||
|
|
||||||
|
err := f.Resolve(&base.Request{URL: testLink}, &base.Options{Path: t.TempDir()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := f.Meta()
|
||||||
|
if meta.Res == nil {
|
||||||
|
t.Fatal("Resolve() resource is nil")
|
||||||
|
}
|
||||||
|
if got, want := meta.Res.Hash, "8867C5E54405FF9452225B66EFEE690A"; got != want {
|
||||||
|
t.Fatalf("Resolve() hash = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
if got, want := meta.Res.Size, int64(4630972416); got != want {
|
||||||
|
t.Fatalf("Resolve() size = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if got, want := meta.Res.Files[0].Name, "cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso"; got != want {
|
||||||
|
t.Fatalf("Resolve() name = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetcherManager_ParseName(t *testing.T) {
|
||||||
|
got := (&FetcherManager{}).ParseName(testLink)
|
||||||
|
want := "cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("ParseName() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,32 @@ 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 {
|
||||||
|
f.resetConnectionForRestart(conn)
|
||||||
|
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 +974,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 +1009,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))
|
||||||
@@ -1363,6 +1414,27 @@ func (f *Fetcher) helpOtherConnection(helper *connection) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) resetConnectionForRestart(conn *connection) {
|
||||||
|
if f.meta.Res.Range {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without range support a new request always starts from byte 0,
|
||||||
|
// so pause/retry must restart instead of continuing from the old offset.
|
||||||
|
if conn.Chunk == nil {
|
||||||
|
conn.Chunk = newChunk(0, 0)
|
||||||
|
} else {
|
||||||
|
conn.Chunk.Begin = 0
|
||||||
|
conn.Chunk.End = 0
|
||||||
|
conn.Chunk.Downloaded = 0
|
||||||
|
}
|
||||||
|
conn.Downloaded = 0
|
||||||
|
conn.Completed = false
|
||||||
|
conn.speed = 0
|
||||||
|
conn.lastSpeedCheck = 0
|
||||||
|
conn.lastSpeedDownload = 0
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Fetcher) resumeConnections() {
|
func (f *Fetcher) resumeConnections() {
|
||||||
// Collect connections to resume while holding the lock
|
// Collect connections to resume while holding the lock
|
||||||
var toResume []*connection
|
var toResume []*connection
|
||||||
@@ -1386,6 +1458,7 @@ func (f *Fetcher) resumeConnections() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
f.resetConnectionForRestart(conn)
|
||||||
// Reset the connection state for resume
|
// Reset the connection state for resume
|
||||||
conn.ctx, conn.cancel = context.WithCancel(f.ctx)
|
conn.ctx, conn.cancel = context.WithCancel(f.ctx)
|
||||||
conn.State = connNotStarted
|
conn.State = connNotStarted
|
||||||
@@ -1425,7 +1498,16 @@ func (f *Fetcher) onDownloadComplete() {
|
|||||||
// Check if all chunks are complete (no remaining bytes)
|
// Check if all chunks are complete (no remaining bytes)
|
||||||
allChunksComplete := true
|
allChunksComplete := true
|
||||||
for _, conn := range f.connections {
|
for _, conn := range f.connections {
|
||||||
if conn.Chunk != nil && conn.Chunk.remain() > 0 && !conn.Completed && conn.State != connCompleted {
|
needsMoreData := false
|
||||||
|
if f.meta.Res.Range {
|
||||||
|
needsMoreData = conn.Chunk != nil && conn.Chunk.remain() > 0
|
||||||
|
} else if f.meta.Res.Size > 0 {
|
||||||
|
needsMoreData = conn.Downloaded < f.meta.Res.Size
|
||||||
|
} else {
|
||||||
|
needsMoreData = !conn.Completed && conn.State != connCompleted
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsMoreData && !conn.Completed && conn.State != connCompleted {
|
||||||
// This connection has remaining work and isn't done
|
// This connection has remaining work and isn't done
|
||||||
// Check if it failed with 403 (server limit) - these can be ignored if other connections completed the work
|
// Check if it failed with 403 (server limit) - these can be ignored if other connections completed the work
|
||||||
if conn.State == connFailed && conn.failed {
|
if conn.State == connFailed && conn.failed {
|
||||||
@@ -1523,6 +1605,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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 {
|
||||||
|
|||||||
+241
-16
@@ -335,9 +335,106 @@ func StartTestOneTimeServer() net.Listener {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartTestSlowStartServer creates a server with configurable delay per request
|
// StartTestNoRangeSlowServer creates a server that always returns the full file
|
||||||
// This allows testing slow-start connection expansion to reach max connections
|
// with Content-Length but does not support Range requests.
|
||||||
func StartTestSlowStartServer(delayPerByte time.Duration) net.Listener {
|
func StartTestNoRangeSlowServer(delayPerChunk time.Duration) net.Listener {
|
||||||
|
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||||
|
|
||||||
|
file, err := os.Open(BuildFile)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, 256*1024)
|
||||||
|
for !sl.isShutdown {
|
||||||
|
n, readErr := file.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, writeErr := writer.Write(buf[:n]); writeErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if flusher, ok := writer.(http.Flusher); ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
if delayPerChunk > 0 {
|
||||||
|
time.Sleep(delayPerChunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return mux
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartTestLowSpeedServer creates a server with configurable delay per request
|
||||||
|
func StartTestLowSpeedServer(delayPerByte time.Duration) net.Listener {
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||||
@@ -386,10 +483,9 @@ func slowCopyNWithDelay(sl *shutdownListener, dst io.Writer, src io.Reader, n in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartTestLimitServer connections limit server
|
// StartTestConLimitServer connections limit server
|
||||||
func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
|
func StartTestConLimitServer(maxConnections int32) net.Listener {
|
||||||
var connections atomic.Int32
|
var connections atomic.Int32
|
||||||
var slowOnce atomic.Bool
|
|
||||||
|
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -402,19 +498,11 @@ func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
|
|||||||
writer.WriteHeader(403)
|
writer.WriteHeader(403)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// First request intentionally delays the first write to trigger a single read timeout,
|
|
||||||
// subsequent requests respond at normal speed.
|
|
||||||
useInitialDelay := delay > 0 && !slowOnce.Swap(true)
|
|
||||||
rangeFileHandle(
|
rangeFileHandle(
|
||||||
writer,
|
writer,
|
||||||
request,
|
request,
|
||||||
nil,
|
nil,
|
||||||
func(file *os.File, n int64) {
|
func(file *os.File, n int64) {
|
||||||
if useInitialDelay {
|
|
||||||
slowCopyAfterDelay(sl, writer, file, n, delay)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slowCopyN(sl, writer, file, n, 0)
|
slowCopyN(sl, writer, file, n, 0)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -496,6 +584,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 {
|
||||||
@@ -562,11 +701,14 @@ func rangeFileHandle(writer http.ResponseWriter, request *http.Request, modifyEn
|
|||||||
writer.WriteHeader(400)
|
writer.WriteHeader(400)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
end, err := strconv.ParseInt(s[1], 10, 64)
|
end := int64(BuildSize - 1)
|
||||||
|
if s[1] != "" {
|
||||||
|
end, err = strconv.ParseInt(s[1], 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writer.WriteHeader(400)
|
writer.WriteHeader(400)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if start < 0 || end < 0 || start > end {
|
if start < 0 || end < 0 || start > end {
|
||||||
writer.WriteHeader(400)
|
writer.WriteHeader(400)
|
||||||
return
|
return
|
||||||
@@ -731,7 +873,7 @@ func StartSocks5Server(usr, pwd string) net.Listener {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0") // 你可以根据需要更改监听地址
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -739,6 +881,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 {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package goprovider
|
||||||
|
|
||||||
|
var mainThreadTasks chan func()
|
||||||
|
|
||||||
|
func RunMainThreadLoop(run func() int) int {
|
||||||
|
if mainThreadTasks != nil {
|
||||||
|
return run()
|
||||||
|
}
|
||||||
|
|
||||||
|
mainThreadTasks = make(chan func())
|
||||||
|
resultCh := make(chan int, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
resultCh <- run()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case task := <-mainThreadTasks:
|
||||||
|
task()
|
||||||
|
case code := <-resultCh:
|
||||||
|
mainThreadTasks = nil
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postMainThreadTask(task func()) bool {
|
||||||
|
if mainThreadTasks == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mainThreadTasks <- task
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
|
||||||
|
package goprovider
|
||||||
|
|
||||||
|
func RunMainThreadLoop(run func() int) int {
|
||||||
|
return run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func postMainThreadTask(func()) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,15 @@
|
|||||||
|
//go:build cgo && webview && !darwin
|
||||||
|
|
||||||
|
package goprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/webview/integrationtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProviderContract(t *testing.T) {
|
||||||
|
integrationtest.RunProviderContract(t, New(), integrationtest.ContractOptions{
|
||||||
|
CookieDomainMode: integrationtest.CookieDomainModeRequired,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !cgo
|
||||||
|
|
||||||
|
package goprovider
|
||||||
|
|
||||||
|
import enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||||
|
|
||||||
|
func New() enginewebview.Provider {
|
||||||
|
return enginewebview.NewUnavailableProvider()
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//go:build cgo && darwin
|
||||||
|
|
||||||
|
package goprovider
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo darwin CFLAGS: -x objective-c
|
||||||
|
#cgo darwin LDFLAGS: -framework Cocoa
|
||||||
|
|
||||||
|
#include <Cocoa/Cocoa.h>
|
||||||
|
|
||||||
|
static void gopeedApplyHeadlessWindowOptions(void *windowPtr, int headless) {
|
||||||
|
if (windowPtr == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSWindow *window = (__bridge NSWindow *)windowPtr;
|
||||||
|
if (headless) {
|
||||||
|
[window orderOut:nil];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import (
|
||||||
|
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||||
|
webview "github.com/GopeedLab/webview_go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyWindowOptions(w webview.WebView, opts enginewebview.OpenOptions) {
|
||||||
|
C.gopeedApplyHeadlessWindowOptions(w.Window(), C.int(boolToInt(opts.Headless)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(value bool) int {
|
||||||
|
if value {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build cgo && !darwin
|
||||||
|
|
||||||
|
package goprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||||
|
webview "github.com/GopeedLab/webview_go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyWindowOptions(_ webview.WebView, _ enginewebview.OpenOptions) {}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
package integrationtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||||
|
)
|
||||||
|
|
||||||
|
const TestUserAgent = "GopeedWebViewIntegration/1.0"
|
||||||
|
|
||||||
|
type ContractOptions struct {
|
||||||
|
AvailabilityTimeout time.Duration
|
||||||
|
CookieDomainMode CookieDomainMode
|
||||||
|
CookieTestURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CookieDomainMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CookieDomainModeRequired CookieDomainMode = iota
|
||||||
|
CookieDomainModeOmit
|
||||||
|
)
|
||||||
|
|
||||||
|
func RunProviderContract(t *testing.T, provider enginewebview.Provider, opts ContractOptions) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
timeout := opts.AvailabilityTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
waitForProviderAvailable(t, provider, timeout)
|
||||||
|
|
||||||
|
page, server, testURL := openTestPage(t, provider, enginewebview.OpenOptions{
|
||||||
|
Headless: true,
|
||||||
|
Title: "Gopeed WebView Integration",
|
||||||
|
Width: 640,
|
||||||
|
Height: 480,
|
||||||
|
UserAgent: TestUserAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
|
||||||
|
t.Fatalf("goto failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ua, err := page.Execute(`() => navigator.userAgent`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("navigator.userAgent execute failed: %v", err)
|
||||||
|
}
|
||||||
|
if ua != TestUserAgent {
|
||||||
|
t.Fatalf("unexpected user agent: got %v want %q", ua, TestUserAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := page.Focus("#name"); err != nil {
|
||||||
|
t.Fatalf("focus failed: %v", err)
|
||||||
|
}
|
||||||
|
activeElement, err := page.Execute(`() => document.activeElement && document.activeElement.id`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("activeElement execute failed: %v", err)
|
||||||
|
}
|
||||||
|
if activeElement != "name" {
|
||||||
|
t.Fatalf("expected active element to be #name, got %v", activeElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := page.Type("#name", "Gopeed", map[string]any{"delay": 5}); err != nil {
|
||||||
|
t.Fatalf("type failed: %v", err)
|
||||||
|
}
|
||||||
|
typeState, err := page.Execute(`() => ({
|
||||||
|
value: document.querySelector('#name').value,
|
||||||
|
mirror: document.querySelector('#name-value').textContent,
|
||||||
|
inputCount: window.__inputEvents.length
|
||||||
|
})`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("type state execute failed: %v", err)
|
||||||
|
}
|
||||||
|
stateMap, ok := typeState.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected type state payload: %T", typeState)
|
||||||
|
}
|
||||||
|
if stateMap["value"] != "Gopeed" || stateMap["mirror"] != "Gopeed" {
|
||||||
|
t.Fatalf("unexpected type state: %#v", stateMap)
|
||||||
|
}
|
||||||
|
if stateMap["inputCount"] != float64(len("Gopeed")) {
|
||||||
|
t.Fatalf("unexpected input event count: %#v", stateMap["inputCount"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := page.Click("#clicker"); err != nil {
|
||||||
|
t.Fatalf("click failed: %v", err)
|
||||||
|
}
|
||||||
|
clickCount, err := page.Execute(`() => document.querySelector('#click-count').textContent`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("click count execute failed: %v", err)
|
||||||
|
}
|
||||||
|
if clickCount != "1" {
|
||||||
|
t.Fatalf("expected click count to be 1, got %v", clickCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
found, err := page.WaitForSelector("#delayed", map[string]any{
|
||||||
|
"timeoutMs": 5000,
|
||||||
|
"pollIntervalMs": 25,
|
||||||
|
"visible": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("waitForSelector failed: %v", err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected delayed selector to appear")
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := page.WaitForFunction(`() => window.__readyValue`, map[string]any{
|
||||||
|
"timeoutMs": 5000,
|
||||||
|
"pollIntervalMs": 25,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("waitForFunction failed: %v", err)
|
||||||
|
}
|
||||||
|
readyValue, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected waitForFunction value: %T", value)
|
||||||
|
}
|
||||||
|
if readyValue["status"] != "ready" {
|
||||||
|
t.Fatalf("unexpected waitForFunction payload: %#v", readyValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
found, err = page.WaitForSelector("#missing", map[string]any{
|
||||||
|
"timeoutMs": 200,
|
||||||
|
"pollIntervalMs": 25,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("waitForSelector timeout case failed: %v", err)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
t.Fatal("expected missing selector wait to time out")
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieTestURL := opts.CookieTestURL
|
||||||
|
if cookieTestURL == "" {
|
||||||
|
cookieTestURL = testURL
|
||||||
|
}
|
||||||
|
runCookieLifecycleAssertions(t, page, server, cookieTestURL, opts.CookieDomainMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForProviderAvailable(t *testing.T, provider enginewebview.Provider, timeout time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if provider != nil && provider.IsAvailable() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("provider did not become available within %s", timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openTestPage(t *testing.T, provider enginewebview.Provider, opts enginewebview.OpenOptions) (*enginewebview.PageHandle, *httptest.Server, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/next":
|
||||||
|
fmt.Fprint(w, `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Next Page</title></head>
|
||||||
|
<body>
|
||||||
|
<h1 id="next-page">Navigation complete</h1>
|
||||||
|
</body>
|
||||||
|
</html>`)
|
||||||
|
default:
|
||||||
|
fmt.Fprint(w, `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Test Page</title></head>
|
||||||
|
<body>
|
||||||
|
<input id="name" type="text" />
|
||||||
|
<div id="name-value"></div>
|
||||||
|
<button id="clicker" type="button">Click me</button>
|
||||||
|
<div id="click-count">0</div>
|
||||||
|
<button id="nav-btn" type="button">Navigate</button>
|
||||||
|
<script>
|
||||||
|
window.__inputEvents = [];
|
||||||
|
window.__readyValue = null;
|
||||||
|
const nameInput = document.getElementById('name');
|
||||||
|
const nameValue = document.getElementById('name-value');
|
||||||
|
nameInput.addEventListener('input', () => {
|
||||||
|
nameValue.textContent = nameInput.value;
|
||||||
|
window.__inputEvents.push(nameInput.value);
|
||||||
|
});
|
||||||
|
document.getElementById('clicker').addEventListener('click', () => {
|
||||||
|
const counter = document.getElementById('click-count');
|
||||||
|
counter.textContent = String(Number(counter.textContent || '0') + 1);
|
||||||
|
});
|
||||||
|
document.getElementById('nav-btn').addEventListener('click', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/next';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
const marker = document.createElement('div');
|
||||||
|
marker.id = 'delayed';
|
||||||
|
marker.textContent = 'Loaded later';
|
||||||
|
document.body.appendChild(marker);
|
||||||
|
}, 150);
|
||||||
|
setTimeout(() => {
|
||||||
|
window.__readyValue = { status: 'ready', source: 'timer' };
|
||||||
|
}, 200);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
runtime := enginewebview.NewRuntime(provider, true)
|
||||||
|
page, err := runtime.Open(map[string]any{
|
||||||
|
"headless": opts.Headless,
|
||||||
|
"debug": opts.Debug,
|
||||||
|
"title": opts.Title,
|
||||||
|
"width": opts.Width,
|
||||||
|
"height": opts.Height,
|
||||||
|
"userAgent": opts.UserAgent,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open webview: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = runtime.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return page, server, server.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCookieLifecycleAssertions(t *testing.T, page *enginewebview.PageHandle, _ *httptest.Server, testURL string, domainMode CookieDomainMode) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
parsedURL, err := url.Parse(testURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse cookie test url failed: %v", err)
|
||||||
|
}
|
||||||
|
cookieDomain := parsedURL.Hostname()
|
||||||
|
|
||||||
|
if err := page.ClearCookies(); err != nil {
|
||||||
|
t.Fatalf("clearCookies before cookie assertions failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleCookie := map[string]any{
|
||||||
|
"name": "visible",
|
||||||
|
"value": "1",
|
||||||
|
"path": "/",
|
||||||
|
}
|
||||||
|
secretCookie := map[string]any{
|
||||||
|
"name": "secret",
|
||||||
|
"value": "2",
|
||||||
|
"path": "/",
|
||||||
|
"httpOnly": true,
|
||||||
|
"expires": time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano),
|
||||||
|
}
|
||||||
|
deleteCookie := map[string]any{
|
||||||
|
"name": "visible",
|
||||||
|
"path": "/",
|
||||||
|
}
|
||||||
|
if domainMode == CookieDomainModeRequired {
|
||||||
|
visibleCookie["domain"] = cookieDomain
|
||||||
|
secretCookie["domain"] = cookieDomain
|
||||||
|
deleteCookie["domain"] = cookieDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := page.SetCookie(visibleCookie); err != nil {
|
||||||
|
t.Fatalf("set visible cookie failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := page.SetCookie(secretCookie); err != nil {
|
||||||
|
t.Fatalf("set secret cookie failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
|
||||||
|
t.Fatalf("goto after setCookie failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
documentCookie, err := page.Execute(`() => document.cookie`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("document.cookie execute failed: %v", err)
|
||||||
|
}
|
||||||
|
documentCookieText, ok := documentCookie.(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected document.cookie payload: %T", documentCookie)
|
||||||
|
}
|
||||||
|
if !strings.Contains(documentCookieText, "visible=1") {
|
||||||
|
t.Fatalf("expected visible cookie in document.cookie, got %q", documentCookieText)
|
||||||
|
}
|
||||||
|
if strings.Contains(documentCookieText, "secret=2") {
|
||||||
|
t.Fatalf("expected httpOnly cookie to be hidden from document.cookie, got %q", documentCookieText)
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies, err := page.GetCookies()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getCookies failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(cookies) < 2 {
|
||||||
|
t.Fatalf("expected at least 2 cookies, got %#v", cookies)
|
||||||
|
}
|
||||||
|
if !hasCookie(cookies, "visible", "1") {
|
||||||
|
t.Fatalf("expected visible cookie in native cookie store, got %#v", cookies)
|
||||||
|
}
|
||||||
|
if !hasCookie(cookies, "secret", "2") {
|
||||||
|
t.Fatalf("expected secret cookie in native cookie store, got %#v", cookies)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := page.DeleteCookie(deleteCookie); err != nil {
|
||||||
|
t.Fatalf("deleteCookie failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
|
||||||
|
t.Fatalf("goto after deleteCookie failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
documentCookie, err = page.Execute(`() => document.cookie`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("document.cookie execute after delete failed: %v", err)
|
||||||
|
}
|
||||||
|
documentCookieText, ok = documentCookie.(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected document.cookie payload after delete: %T", documentCookie)
|
||||||
|
}
|
||||||
|
if strings.Contains(documentCookieText, "visible=1") {
|
||||||
|
t.Fatalf("expected visible cookie to be deleted, got %q", documentCookieText)
|
||||||
|
}
|
||||||
|
if strings.Contains(documentCookieText, "secret=2") {
|
||||||
|
t.Fatalf("expected httpOnly cookie to remain hidden after delete, got %q", documentCookieText)
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies, err = page.GetCookies()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getCookies after delete failed: %v", err)
|
||||||
|
}
|
||||||
|
if hasCookie(cookies, "visible", "1") {
|
||||||
|
t.Fatalf("expected visible cookie to be removed from native store, got %#v", cookies)
|
||||||
|
}
|
||||||
|
if !hasCookie(cookies, "secret", "2") {
|
||||||
|
t.Fatalf("expected secret cookie to remain in native store after delete, got %#v", cookies)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := page.ClearCookies(); err != nil {
|
||||||
|
t.Fatalf("clearCookies failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
|
||||||
|
t.Fatalf("goto after clearCookies failed: %v", err)
|
||||||
|
}
|
||||||
|
documentCookie, err = page.Execute(`() => document.cookie`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("document.cookie execute after clear failed: %v", err)
|
||||||
|
}
|
||||||
|
documentCookieText, ok = documentCookie.(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected document.cookie payload after clear: %T", documentCookie)
|
||||||
|
}
|
||||||
|
if documentCookieText != "" {
|
||||||
|
t.Fatalf("expected document.cookie to be empty after clear, got %q", documentCookieText)
|
||||||
|
}
|
||||||
|
cookies, err = page.GetCookies()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getCookies after clear failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(cookies) != 0 {
|
||||||
|
t.Fatalf("expected cookie store to be empty after clear, got %#v", cookies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasCookie(cookies []enginewebview.Cookie, name string, value string) bool {
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
if cookie.Name == name && cookie.Value == value {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
# rpcprovider
|
||||||
|
|
||||||
|
`rpcprovider` is the RPC-backed implementation of Gopeed's `webview.Provider` interface.
|
||||||
|
|
||||||
|
It is intended for environments where WebView capability is owned by another process, most notably mobile hosts. This package does not implement a WebView itself. Instead, it translates the `pkg/download/engine/webview` page API into single-request `POST + JSON` calls sent to a local host-side RPC service.
|
||||||
|
|
||||||
|
## When to use it
|
||||||
|
|
||||||
|
Use `rpcprovider` for entrypoints such as `bind/mobile`, where the Go process cannot directly own a native WebView.
|
||||||
|
|
||||||
|
Do not use it for desktop or `cmd/web` local WebView integration. Those entrypoints should use `internal/webview/goprovider` instead.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The provider is configured through `pkg/download/engine/webview.RPCConfig`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type RPCConfig struct {
|
||||||
|
Network string `json:"network"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `Network` currently supports `tcp` and `unix`.
|
||||||
|
- `Address` examples:
|
||||||
|
- `127.0.0.1:38765`
|
||||||
|
- `/path/to/webview.sock`
|
||||||
|
- `Token` is optional. When set, the client sends `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
provider := rpcprovider.New(webview.RPCConfig{
|
||||||
|
Network: "tcp",
|
||||||
|
Address: "127.0.0.1:38765",
|
||||||
|
Token: "secret-token",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transport
|
||||||
|
|
||||||
|
- Endpoint: `POST /webview`
|
||||||
|
- Content type: `application/json`
|
||||||
|
- One request, one response
|
||||||
|
- No `id` field yet
|
||||||
|
- The envelope is intentionally close to JSON-RPC so it can evolve later without renaming the methods or reshaping the payloads
|
||||||
|
|
||||||
|
Request example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "page.execute",
|
||||||
|
"params": {
|
||||||
|
"pageId": "page-1",
|
||||||
|
"expression": "document.title",
|
||||||
|
"args": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Successful response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"title": "Example"
|
||||||
|
},
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Failure response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": null,
|
||||||
|
"error": {
|
||||||
|
"code": "PAGE_NOT_FOUND",
|
||||||
|
"message": "page not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported methods
|
||||||
|
|
||||||
|
- `webview.isAvailable`
|
||||||
|
- `page.open`
|
||||||
|
- `page.addInitScript`
|
||||||
|
- `page.goto`
|
||||||
|
- `page.execute`
|
||||||
|
- `page.getCookies`
|
||||||
|
- `page.setCookie`
|
||||||
|
- `page.deleteCookie`
|
||||||
|
- `page.clearCookies`
|
||||||
|
- `page.close`
|
||||||
|
|
||||||
|
The canonical wire models for these methods are defined in:
|
||||||
|
|
||||||
|
- [pkg/download/engine/webview/rpc.go](../../../pkg/download/engine/webview/rpc.go)
|
||||||
|
- [pkg/download/engine/webview/runtime.go](../../../pkg/download/engine/webview/runtime.go)
|
||||||
|
|
||||||
|
The README is only a guide. The Go source above is the authoritative protocol reference.
|
||||||
|
|
||||||
|
## Method reference
|
||||||
|
|
||||||
|
Each method below uses the common envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "<method-name>",
|
||||||
|
"params": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
and returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": ...,
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
or:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": null,
|
||||||
|
"error": {
|
||||||
|
"code": "<ERROR_CODE>",
|
||||||
|
"message": "<message>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `webview.isAvailable`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "webview.isAvailable",
|
||||||
|
"params": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"available": true
|
||||||
|
},
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `page.open`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "page.open",
|
||||||
|
"params": {
|
||||||
|
"headless": true,
|
||||||
|
"debug": false,
|
||||||
|
"title": "Gopeed WebView",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"userAgent": "Mozilla/5.0 ..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"pageId": "page-1"
|
||||||
|
},
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `page.addInitScript`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "page.addInitScript",
|
||||||
|
"params": {
|
||||||
|
"pageId": "page-1",
|
||||||
|
"script": "window.__READY__ = true;"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {},
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `page.goto`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "page.goto",
|
||||||
|
"params": {
|
||||||
|
"pageId": "page-1",
|
||||||
|
"url": "https://example.com",
|
||||||
|
"timeoutMs": 15000,
|
||||||
|
"waitUntil": "domcontentloaded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {},
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`waitUntil` currently supports `load` and `domcontentloaded`. The default is `load`.
|
||||||
|
|
||||||
|
### `page.execute`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "page.execute",
|
||||||
|
"params": {
|
||||||
|
"pageId": "page-1",
|
||||||
|
"expression": "document.title",
|
||||||
|
"args": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": "Example Domain",
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `page.getCookies`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "page.getCookies",
|
||||||
|
"params": {
|
||||||
|
"pageId": "page-1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"name": "session",
|
||||||
|
"value": "abc",
|
||||||
|
"domain": ".example.com",
|
||||||
|
"path": "/",
|
||||||
|
"expires": "2026-03-26T12:00:00Z",
|
||||||
|
"secure": true,
|
||||||
|
"httpOnly": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `page.setCookie`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "page.setCookie",
|
||||||
|
"params": {
|
||||||
|
"pageId": "page-1",
|
||||||
|
"cookie": {
|
||||||
|
"name": "session",
|
||||||
|
"value": "abc",
|
||||||
|
"domain": ".example.com",
|
||||||
|
"path": "/",
|
||||||
|
"expires": "2026-03-26T12:00:00Z",
|
||||||
|
"secure": true,
|
||||||
|
"httpOnly": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {},
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `page.deleteCookie`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "page.deleteCookie",
|
||||||
|
"params": {
|
||||||
|
"pageId": "page-1",
|
||||||
|
"cookie": {
|
||||||
|
"name": "session",
|
||||||
|
"domain": ".example.com",
|
||||||
|
"path": "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {},
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `page.clearCookies`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "page.clearCookies",
|
||||||
|
"params": {
|
||||||
|
"pageId": "page-1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {},
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `page.close`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"method": "page.close",
|
||||||
|
"params": {
|
||||||
|
"pageId": "page-1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": {},
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Method semantics
|
||||||
|
|
||||||
|
- `page.open`
|
||||||
|
- Creates a page session
|
||||||
|
- Returns `pageId`
|
||||||
|
- `page.addInitScript`
|
||||||
|
- Registers a page initialization script
|
||||||
|
- `page.goto`
|
||||||
|
- Goes to the target URL
|
||||||
|
- The server should return only after navigation completes or fails
|
||||||
|
- `page.execute`
|
||||||
|
- Executes an expression or serialized function body
|
||||||
|
- The returned value must be JSON-serializable
|
||||||
|
- `page.getCookies`
|
||||||
|
- Returns cookies from the underlying native WebView cookie store
|
||||||
|
- `page.setCookie`
|
||||||
|
- Inserts or updates a cookie
|
||||||
|
- `page.deleteCookie`
|
||||||
|
- Deletes a cookie using `name/domain/path`
|
||||||
|
- `page.clearCookies`
|
||||||
|
- Clears the current cookie store
|
||||||
|
- `page.close`
|
||||||
|
- Closes the page and releases related resources
|
||||||
|
|
||||||
|
## Cookie model
|
||||||
|
|
||||||
|
Cookies use `runtime/webview.Cookie`.
|
||||||
|
|
||||||
|
Example payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "session",
|
||||||
|
"value": "abc",
|
||||||
|
"domain": ".example.com",
|
||||||
|
"path": "/",
|
||||||
|
"expires": "2026-03-26T12:00:00Z",
|
||||||
|
"secure": true,
|
||||||
|
"httpOnly": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `expires` uses RFC3339 / RFC3339Nano string form.
|
||||||
|
- If the host platform supports a full native cookie store, it should return fields such as `httpOnly` and `secure`.
|
||||||
|
- `setCookie -> goto` is a valid pattern for preloading authenticated session state.
|
||||||
|
|
||||||
|
## Error codes
|
||||||
|
|
||||||
|
The current error code set is defined in `pkg/download/engine/webview/rpc.go`:
|
||||||
|
|
||||||
|
- `INVALID_REQUEST`
|
||||||
|
- `UNKNOWN_METHOD`
|
||||||
|
- `UNAVAILABLE`
|
||||||
|
- `BROWSER_NOT_FOUND`
|
||||||
|
- `PAGE_NOT_FOUND`
|
||||||
|
- `NAVIGATION_FAILED`
|
||||||
|
- `EVALUATION_FAILED`
|
||||||
|
- `TIMEOUT`
|
||||||
|
- `INTERNAL_ERROR`
|
||||||
|
|
||||||
|
`BROWSER_NOT_FOUND` is a historical leftover from an earlier shape of the protocol. The current protocol no longer has a separate browser model, so this can be cleaned up later if the protocol is tightened further.
|
||||||
|
|
||||||
|
## Current protocol boundary
|
||||||
|
|
||||||
|
This protocol is intentionally limited:
|
||||||
|
|
||||||
|
- Single-direction request/response only
|
||||||
|
- Not REST-style resource routing
|
||||||
|
- No bidirectional events
|
||||||
|
- No streaming event subscriptions
|
||||||
|
- No host-initiated push channel
|
||||||
|
|
||||||
|
If the protocol needs to evolve toward full JSON-RPC later, the intended migration path is to keep the existing `method/params/result/error` structure and add:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- notifications
|
||||||
|
- a bidirectional transport model
|
||||||
|
|
||||||
|
## Host implementation guidance
|
||||||
|
|
||||||
|
A host implementation should usually:
|
||||||
|
|
||||||
|
- Listen on a local `tcp` address or `unix socket`
|
||||||
|
- Expose `POST /webview`
|
||||||
|
- Validate the bearer token if configured
|
||||||
|
- Maintain `pageId -> native webview/session`
|
||||||
|
- Own page lifecycle, cleanup, and timeout handling
|
||||||
|
|
||||||
|
With that split, Gopeed stays a pure RPC client and does not need to know how the host's native WebView is implemented.
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package rpcprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
client *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg enginewebview.RPCConfig) enginewebview.Provider {
|
||||||
|
return &Provider{
|
||||||
|
client: NewClient(cfg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) IsAvailable() bool {
|
||||||
|
if p == nil || p.client == nil || !p.client.Enabled() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var result enginewebview.IsAvailableResult
|
||||||
|
if err := p.client.Call(enginewebview.MethodIsAvailable, enginewebview.IsAvailableParams{}, &result); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return result.Available
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) {
|
||||||
|
if p == nil || p.client == nil || !p.IsAvailable() {
|
||||||
|
return nil, enginewebview.ErrUnavailable
|
||||||
|
}
|
||||||
|
var result enginewebview.PageOpenResult
|
||||||
|
if err := p.client.Call(enginewebview.MethodPageOpen, enginewebview.NewPageOpenParams(opts), &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &page{
|
||||||
|
client: p.client,
|
||||||
|
id: result.PageID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type page struct {
|
||||||
|
client *Client
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *page) AddInitScript(script string) error {
|
||||||
|
return p.client.Call(enginewebview.MethodPageAddInitScript, enginewebview.PageAddInitScriptParams{
|
||||||
|
PageID: p.id,
|
||||||
|
Script: script,
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *page) Goto(url string, opts enginewebview.GotoOptions) error {
|
||||||
|
return p.client.Call(enginewebview.MethodPageGoto, enginewebview.NewPageGotoParams(p.id, url, opts), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *page) Execute(expression string, args ...any) (any, error) {
|
||||||
|
var result any
|
||||||
|
if err := p.client.Call(enginewebview.MethodPageExecute, enginewebview.PageExecuteParams{
|
||||||
|
PageID: p.id,
|
||||||
|
Expression: expression,
|
||||||
|
Args: args,
|
||||||
|
}, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *page) GetCookies() ([]enginewebview.Cookie, error) {
|
||||||
|
var result []enginewebview.Cookie
|
||||||
|
if err := p.client.Call(enginewebview.MethodPageGetCookies, enginewebview.PageGetCookiesParams{
|
||||||
|
PageID: p.id,
|
||||||
|
}, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return []enginewebview.Cookie{}, nil
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *page) SetCookie(cookie enginewebview.Cookie) error {
|
||||||
|
return p.client.Call(enginewebview.MethodPageSetCookie, enginewebview.PageSetCookieParams{
|
||||||
|
PageID: p.id,
|
||||||
|
Cookie: cookie,
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *page) DeleteCookie(cookie enginewebview.Cookie) error {
|
||||||
|
return p.client.Call(enginewebview.MethodPageDeleteCookie, enginewebview.PageDeleteCookieParams{
|
||||||
|
PageID: p.id,
|
||||||
|
Cookie: cookie,
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *page) ClearCookies() error {
|
||||||
|
return p.client.Call(enginewebview.MethodPageClearCookies, enginewebview.PageClearCookiesParams{
|
||||||
|
PageID: p.id,
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *page) Close() error {
|
||||||
|
return p.client.Call(enginewebview.MethodPageClose, enginewebview.PageCloseParams{
|
||||||
|
PageID: p.id,
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
endpoint string
|
||||||
|
token string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(cfg enginewebview.RPCConfig) *Client {
|
||||||
|
client := &Client{token: cfg.Token}
|
||||||
|
if !cfg.Enabled() {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
switch strings.ToLower(cfg.Network) {
|
||||||
|
case "unix":
|
||||||
|
client.endpoint = "http://unix" + enginewebview.RPCEndpointPath
|
||||||
|
client.http = &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
var d net.Dialer
|
||||||
|
return d.DialContext(ctx, "unix", cfg.Address)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
client.endpoint = "http://" + cfg.Address + enginewebview.RPCEndpointPath
|
||||||
|
client.http = &http.Client{}
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Enabled() bool {
|
||||||
|
return c != nil && c.endpoint != "" && c.http != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Call(method string, params any, result any) error {
|
||||||
|
if !c.Enabled() {
|
||||||
|
return enginewebview.ErrUnavailable
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(enginewebview.RPCRequest{
|
||||||
|
Method: method,
|
||||||
|
Params: params,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, c.endpoint, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("webview rpc status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body enginewebview.RPCResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if body.Error != nil {
|
||||||
|
return body.Error
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(body.Result) == 0 || string(body.Result) == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(body.Result, result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//go:build webview && darwin
|
||||||
|
|
||||||
|
package rpcprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/webview/integrationtest"
|
||||||
|
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
webViewRPCNetwork = flag.String("webview-rpc-network", "unix", "WebView RPC network for integration tests")
|
||||||
|
webViewRPCAddress = flag.String("webview-rpc-address", defaultWebViewRPCAddress(), "WebView RPC address for integration tests")
|
||||||
|
webViewRPCToken = flag.String("webview-rpc-token", "", "WebView RPC token for integration tests")
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProviderContract(t *testing.T) {
|
||||||
|
provider := New(enginewebview.RPCConfig{
|
||||||
|
Network: *webViewRPCNetwork,
|
||||||
|
Address: *webViewRPCAddress,
|
||||||
|
Token: *webViewRPCToken,
|
||||||
|
})
|
||||||
|
integrationtest.RunProviderContract(t, provider, integrationtest.ContractOptions{
|
||||||
|
AvailabilityTimeout: 5 * time.Minute,
|
||||||
|
CookieDomainMode: integrationtest.CookieDomainModeRequired,
|
||||||
|
CookieTestURL: "https://example.com/",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultWebViewRPCAddress() string {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(homeDir, "Library", "Application Support", "com.gopeed.gopeed", "gopeed_webview.sock")
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package rpcprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProviderIsAvailableOverUnixSocket(t *testing.T) {
|
||||||
|
socketPath := filepath.Join("/tmp", "gopeed-rpcwebview-"+t.Name()+".sock")
|
||||||
|
listener, err := net.Listen("unix", socketPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
defer os.Remove(socketPath)
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req enginewebview.RPCRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if req.Method != enginewebview.MethodIsAvailable {
|
||||||
|
t.Fatalf("unexpected unix method: %s", req.Method)
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"result": enginewebview.IsAvailableResult{Available: true},
|
||||||
|
"error": nil,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
defer server.Close()
|
||||||
|
go server.Serve(listener)
|
||||||
|
|
||||||
|
provider := New(enginewebview.RPCConfig{
|
||||||
|
Network: "unix",
|
||||||
|
Address: socketPath,
|
||||||
|
})
|
||||||
|
if !provider.IsAvailable() {
|
||||||
|
t.Fatal("expected unix provider to be available")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package rpcprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProviderLifecycleOverHTTP(t *testing.T) {
|
||||||
|
var calls []enginewebview.RPCRequest
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Fatalf("unexpected method: %s", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != enginewebview.RPCEndpointPath {
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer secret-token" {
|
||||||
|
t.Fatalf("unexpected auth header: %q", got)
|
||||||
|
}
|
||||||
|
var req enginewebview.RPCRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
calls = append(calls, req)
|
||||||
|
|
||||||
|
var body any
|
||||||
|
switch req.Method {
|
||||||
|
case enginewebview.MethodIsAvailable:
|
||||||
|
body = map[string]any{
|
||||||
|
"result": enginewebview.IsAvailableResult{Available: true},
|
||||||
|
"error": nil,
|
||||||
|
}
|
||||||
|
case enginewebview.MethodPageOpen:
|
||||||
|
body = map[string]any{
|
||||||
|
"result": enginewebview.PageOpenResult{PageID: "page-open-1"},
|
||||||
|
"error": nil,
|
||||||
|
}
|
||||||
|
case enginewebview.MethodPageAddInitScript, enginewebview.MethodPageGoto:
|
||||||
|
body = map[string]any{
|
||||||
|
"result": enginewebview.EmptyResult{},
|
||||||
|
"error": nil,
|
||||||
|
}
|
||||||
|
case enginewebview.MethodPageExecute:
|
||||||
|
body = map[string]any{
|
||||||
|
"result": map[string]any{"title": "Example"},
|
||||||
|
"error": nil,
|
||||||
|
}
|
||||||
|
case enginewebview.MethodPageClose:
|
||||||
|
body = map[string]any{
|
||||||
|
"result": enginewebview.EmptyResult{},
|
||||||
|
"error": nil,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method: %s", req.Method)
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := enginewebview.RPCConfig{
|
||||||
|
Network: "tcp",
|
||||||
|
Address: server.Listener.Addr().String(),
|
||||||
|
Token: "secret-token",
|
||||||
|
}
|
||||||
|
provider := New(cfg)
|
||||||
|
if !provider.IsAvailable() {
|
||||||
|
t.Fatal("expected provider to be available")
|
||||||
|
}
|
||||||
|
|
||||||
|
opener, ok := provider.(enginewebview.Opener)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected provider to implement runtime webview opener")
|
||||||
|
}
|
||||||
|
opened, err := opener.Open(enginewebview.OpenOptions{
|
||||||
|
Headless: true,
|
||||||
|
Width: 1280,
|
||||||
|
Height: 720,
|
||||||
|
Title: "Gopeed",
|
||||||
|
UserAgent: "UA",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := opened.AddInitScript("window.__TEST__ = true"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := opened.Goto("https://example.com", enginewebview.GotoOptions{
|
||||||
|
TimeoutMS: 3210,
|
||||||
|
WaitUntil: "domcontentloaded",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
value, err := opened.Execute("document.title")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, ok := value.(map[string]any)
|
||||||
|
if !ok || got["title"] != "Example" {
|
||||||
|
t.Fatalf("unexpected evaluate result: %#v", value)
|
||||||
|
}
|
||||||
|
if err := opened.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedMethods := []string{
|
||||||
|
enginewebview.MethodIsAvailable,
|
||||||
|
enginewebview.MethodIsAvailable,
|
||||||
|
enginewebview.MethodPageOpen,
|
||||||
|
enginewebview.MethodPageAddInitScript,
|
||||||
|
enginewebview.MethodPageGoto,
|
||||||
|
enginewebview.MethodPageExecute,
|
||||||
|
enginewebview.MethodPageClose,
|
||||||
|
}
|
||||||
|
if len(calls) != len(expectedMethods) {
|
||||||
|
t.Fatalf("unexpected call count: got %d want %d", len(calls), len(expectedMethods))
|
||||||
|
}
|
||||||
|
for i, method := range expectedMethods {
|
||||||
|
if calls[i].Method != method {
|
||||||
|
t.Fatalf("unexpected method at %d: got %s want %s", i, calls[i].Method, method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gotoParamsJSON, err := json.Marshal(calls[4].Params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var gotoParams enginewebview.PageGotoParams
|
||||||
|
if err := json.Unmarshal(gotoParamsJSON, &gotoParams); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotoParams.WaitUntil != "domcontentloaded" {
|
||||||
|
t.Fatalf("unexpected waitUntil: %q", gotoParams.WaitUntil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderLaunchUnavailableWithoutEndpoint(t *testing.T) {
|
||||||
|
provider := New(enginewebview.RPCConfig{})
|
||||||
|
if provider.IsAvailable() {
|
||||||
|
t.Fatal("expected empty provider to be unavailable")
|
||||||
|
}
|
||||||
|
opener, ok := provider.(enginewebview.Opener)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected provider to implement runtime webview opener")
|
||||||
|
}
|
||||||
|
if _, err := opener.Open(enginewebview.OpenOptions{}); err == nil || err.Error() != enginewebview.ErrUnavailable.Error() {
|
||||||
|
t.Fatalf("unexpected launch error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPageSetCookieParamsOmitsZeroExpires(t *testing.T) {
|
||||||
|
params := enginewebview.PageSetCookieParams{
|
||||||
|
PageID: "page-1",
|
||||||
|
Cookie: enginewebview.Cookie{
|
||||||
|
Name: "visible",
|
||||||
|
Value: "1",
|
||||||
|
Domain: "example.com",
|
||||||
|
Path: "/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(payload) == "" {
|
||||||
|
t.Fatal("expected non-empty json payload")
|
||||||
|
}
|
||||||
|
if got := string(payload); strings.Contains(got, `"expires"`) {
|
||||||
|
t.Fatalf("unexpected zero expires in payload: %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
params.Cookie.Expires = time.Now().UTC().Truncate(time.Second)
|
||||||
|
payload, err = json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := string(payload); !strings.Contains(got, `"expires"`) {
|
||||||
|
t.Fatalf("expected expires in payload: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-2
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
// Request download request
|
// Request download request
|
||||||
type Request struct {
|
type Request struct {
|
||||||
|
RawURL string `json:"rawUrl"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Extra any `json:"extra"`
|
Extra any `json:"extra"`
|
||||||
// Labels is used to mark the download task
|
// Labels is used to mark the download task
|
||||||
@@ -24,6 +25,24 @@ type Request struct {
|
|||||||
SkipVerifyCert bool `json:"skipVerifyCert"`
|
SkipVerifyCert bool `json:"skipVerifyCert"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLabels replaces all labels on the request.
|
||||||
|
func (r *Request) SetLabels(labels map[string]string) {
|
||||||
|
r.Labels = labels
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutLabel sets a label on the request.
|
||||||
|
func (r *Request) PutLabel(key, value string) {
|
||||||
|
if r.Labels == nil {
|
||||||
|
r.Labels = make(map[string]string)
|
||||||
|
}
|
||||||
|
r.Labels[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// DelLabel deletes a label from the request.
|
||||||
|
func (r *Request) DelLabel(key string) {
|
||||||
|
delete(r.Labels, key)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Request) Validate() error {
|
func (r *Request) Validate() error {
|
||||||
if r.URL == "" {
|
if r.URL == "" {
|
||||||
return fmt.Errorf("invalid request url")
|
return fmt.Errorf("invalid request url")
|
||||||
@@ -78,7 +97,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 +143,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 +203,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 +222,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 +262,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 +280,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,
|
||||||
}
|
}
|
||||||
|
|||||||
+481
-32
File diff suppressed because it is too large.
Load diff
+770
-32
File diff suppressed because it is too large.
Load diff
Loaded 50 of 173 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user