mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-13 20:03:46 +00:00
Compare 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 |
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
|
||||||
+15
-20
@@ -13,8 +13,8 @@ on:
|
|||||||
default: "false"
|
default: "false"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.24"
|
GO_VERSION: "1.25"
|
||||||
FLUTTER_VERSION: "3.41.2"
|
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 }}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ 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
|
||||||
@@ -187,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/*
|
||||||
@@ -198,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
|
||||||
@@ -224,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
|
||||||
@@ -315,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
|
||||||
@@ -324,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
|
||||||
@@ -339,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
|
||||||
@@ -418,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
|
||||||
@@ -487,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
|
||||||
@@ -502,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
|
||||||
@@ -552,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
|
||||||
@@ -594,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
|
||||||
@@ -627,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
|
||||||
|
|||||||
+71
-15
@@ -28,8 +28,8 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.24"
|
GO_VERSION: "1.25"
|
||||||
FLUTTER_VERSION: "3.41.2"
|
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,6 +69,58 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
files: ./coverage.txt
|
files: ./coverage.txt
|
||||||
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
||||||
|
test-webview-integration:
|
||||||
|
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 }}
|
||||||
|
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:
|
build-desktop:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -80,7 +136,7 @@ jobs:
|
|||||||
flutter_channel: "main"
|
flutter_channel: "main"
|
||||||
name: Build desktop check (${{ matrix.os }})
|
name: Build desktop check (${{ matrix.os }})
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
needs: [test]
|
needs: [ test ]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Enable long paths for flutter main branch checks
|
- name: Enable long paths for flutter main branch checks
|
||||||
@@ -109,7 +165,7 @@ jobs:
|
|||||||
"CGO_ENABLED=1" >> $env:GITHUB_ENV
|
"CGO_ENABLED=1" >> $env:GITHUB_ENV
|
||||||
"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
|
||||||
@@ -129,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
|
||||||
@@ -138,11 +194,11 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [macos-14, ubuntu-latest]
|
os: [ macos-14, ubuntu-latest ]
|
||||||
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
|
||||||
@@ -171,10 +227,10 @@ jobs:
|
|||||||
build-web:
|
build-web:
|
||||||
name: Build web check
|
name: Build web check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
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
|
||||||
@@ -192,10 +248,10 @@ jobs:
|
|||||||
build-docker:
|
build-docker:
|
||||||
name: Build docker check
|
name: Build docker check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
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
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.24.11-alpine3.23 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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
[](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
|
||||||
|
|||||||
+6
-6
@@ -4,7 +4,7 @@
|
|||||||
[](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+
|
||||||
|
|
||||||
### 📋 クローン
|
### 📋 クローン
|
||||||
|
|||||||
+6
-6
@@ -4,7 +4,7 @@
|
|||||||
[](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
|
||||||
|
|||||||
+6
-6
@@ -4,7 +4,7 @@
|
|||||||
[](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+
|
||||||
|
|
||||||
### 📋 克隆项目
|
### 📋 克隆项目
|
||||||
|
|||||||
+6
-6
@@ -4,7 +4,7 @@
|
|||||||
[](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+
|
||||||
|
|
||||||
### 📋 克隆項目
|
### 📋 克隆項目
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -386,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=
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -127,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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -618,6 +618,7 @@ func (f *Fetcher) doStart() error {
|
|||||||
for _, conn := range f.connections {
|
for _, conn := range f.connections {
|
||||||
// Reset connections that can be retried
|
// Reset connections that can be retried
|
||||||
if !conn.Completed && conn.State != connCompleted {
|
if !conn.Completed && conn.State != connCompleted {
|
||||||
|
f.resetConnectionForRestart(conn)
|
||||||
conn.State = connNotStarted
|
conn.State = connNotStarted
|
||||||
conn.failed = false
|
conn.failed = false
|
||||||
conn.retryTimes = 0
|
conn.retryTimes = 0
|
||||||
@@ -1413,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
|
||||||
@@ -1436,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
|
||||||
@@ -1475,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 {
|
||||||
|
|||||||
@@ -299,6 +299,53 @@ func TestFetcher_DownloadContinue(t *testing.T) {
|
|||||||
downloadContinue(listener, 16, t)
|
downloadContinue(listener, 16, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFetcher_DownloadContinue_NoRangeRestart(t *testing.T) {
|
||||||
|
listener := test.StartTestNoRangeSlowServer(time.Millisecond)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
fetcher := downloadReady(listener, 4, t)
|
||||||
|
if err := fetcher.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
|
stats := fetcher.Stats().(*http.Stats)
|
||||||
|
if len(stats.Connections) != 1 {
|
||||||
|
t.Fatalf("expected a single non-range connection, got %d", len(stats.Connections))
|
||||||
|
}
|
||||||
|
if stats.Connections[0].Downloaded <= 0 || stats.Connections[0].Downloaded >= test.BuildSize {
|
||||||
|
t.Fatalf("expected partial download before pause, got %d", stats.Connections[0].Downloaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fetcher.Pause(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := fetcher.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := fetcher.Wait(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalStats := fetcher.Stats().(*http.Stats)
|
||||||
|
if len(finalStats.Connections) != 1 {
|
||||||
|
t.Fatalf("expected a single non-range connection after resume, got %d", len(finalStats.Connections))
|
||||||
|
}
|
||||||
|
if finalStats.Connections[0].Downloaded != test.BuildSize {
|
||||||
|
t.Fatalf("downloaded bytes should restart cleanly: got %d, want %d", finalStats.Connections[0].Downloaded, test.BuildSize)
|
||||||
|
}
|
||||||
|
if total := fetcher.Progress().TotalDownloaded(); total != test.BuildSize {
|
||||||
|
t.Fatalf("progress total = %d, want %d", total, test.BuildSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := test.FileMd5(test.BuildFile)
|
||||||
|
got := test.FileMd5(test.DownloadFile)
|
||||||
|
if want != got {
|
||||||
|
t.Errorf("Download() got = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFetcher_DownloadChunked(t *testing.T) {
|
func TestFetcher_DownloadChunked(t *testing.T) {
|
||||||
listener := test.StartTestCustomServer()
|
listener := test.StartTestCustomServer()
|
||||||
defer listener.Close()
|
defer listener.Close()
|
||||||
@@ -329,7 +376,7 @@ func TestFetcher_DownloadError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFetcher_DownloadLimit(t *testing.T) {
|
func TestFetcher_DownloadLimit(t *testing.T) {
|
||||||
listener := test.StartTestLimitServer(4, 0)
|
listener := test.StartTestConLimitServer(4)
|
||||||
defer listener.Close()
|
defer listener.Close()
|
||||||
|
|
||||||
downloadNormal(listener, 1, t)
|
downloadNormal(listener, 1, t)
|
||||||
@@ -567,7 +614,7 @@ func TestFetcher_SlowStartExpansion(t *testing.T) {
|
|||||||
os.Remove(test.DownloadFile)
|
os.Remove(test.DownloadFile)
|
||||||
|
|
||||||
// Use 100ns delay per byte for faster test (~10MB/s theoretical)
|
// Use 100ns delay per byte for faster test (~10MB/s theoretical)
|
||||||
listener := test.StartTestSlowStartServer(100 * time.Nanosecond)
|
listener := test.StartTestLowSpeedServer(100 * time.Nanosecond)
|
||||||
|
|
||||||
// Ensure cleanup happens before next subtest
|
// Ensure cleanup happens before next subtest
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
@@ -711,7 +758,7 @@ func TestFetcher_AsyncPrefetch(t *testing.T) {
|
|||||||
t.Run("PrefetchPartial", func(t *testing.T) {
|
t.Run("PrefetchPartial", func(t *testing.T) {
|
||||||
// Use slow server with 100 nanosecond delay per byte
|
// Use slow server with 100 nanosecond delay per byte
|
||||||
// This means ~10MB/s speed, so 100ms should download ~1MB
|
// This means ~10MB/s speed, so 100ms should download ~1MB
|
||||||
listener := test.StartTestSlowStartServer(100 * time.Nanosecond)
|
listener := test.StartTestLowSpeedServer(100 * time.Nanosecond)
|
||||||
defer listener.Close()
|
defer listener.Close()
|
||||||
|
|
||||||
fetcher := buildFetcher()
|
fetcher := buildFetcher()
|
||||||
@@ -1384,8 +1431,8 @@ func TestFetcher_Patch_Extra(t *testing.T) {
|
|||||||
Method: "POST", // Update method
|
Method: "POST", // Update method
|
||||||
// Body is empty, should NOT update
|
// Body is empty, should NOT update
|
||||||
Header: map[string]string{
|
Header: map[string]string{
|
||||||
"X-Custom": "modified", // Overwrite existing
|
"X-Custom": "modified", // Overwrite existing
|
||||||
"X-New": "added", // Add new
|
"X-New": "added", // Add new
|
||||||
// Authorization is not in patch, should remain
|
// Authorization is not in patch, should remain
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+49
-19
@@ -335,6 +335,43 @@ func StartTestOneTimeServer() net.Listener {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartTestNoRangeSlowServer creates a server that always returns the full file
|
||||||
|
// with Content-Length but does not support Range requests.
|
||||||
|
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.
|
// 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.
|
// 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
|
// When the temporary URL expires (returns 403), the client should retry with the original URL
|
||||||
@@ -396,9 +433,8 @@ func StartTestExpiringRedirectServer(requestsBeforeExpire int32, delayPerByte ti
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartTestSlowStartServer creates a server with configurable delay per request
|
// StartTestLowSpeedServer creates a server with configurable delay per request
|
||||||
// This allows testing slow-start connection expansion to reach max connections
|
func StartTestLowSpeedServer(delayPerByte time.Duration) net.Listener {
|
||||||
func StartTestSlowStartServer(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) {
|
||||||
@@ -447,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()
|
||||||
@@ -463,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)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -674,10 +701,13 @@ 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 err != nil {
|
if s[1] != "" {
|
||||||
writer.WriteHeader(400)
|
end, err = strconv.ParseInt(s[1], 10, 64)
|
||||||
return
|
if err != nil {
|
||||||
|
writer.WriteHeader(400)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if start < 0 || end < 0 || start > end {
|
if start < 0 || end < 0 || start > end {
|
||||||
writer.WriteHeader(400)
|
writer.WriteHeader(400)
|
||||||
@@ -843,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-2
@@ -14,8 +14,9 @@ import (
|
|||||||
|
|
||||||
// Request download request
|
// Request download request
|
||||||
type Request struct {
|
type Request struct {
|
||||||
URL string `json:"url"`
|
RawURL string `json:"rawUrl"`
|
||||||
Extra any `json:"extra"`
|
URL string `json:"url"`
|
||||||
|
Extra any `json:"extra"`
|
||||||
// Labels is used to mark the download task
|
// Labels is used to mark the download task
|
||||||
Labels map[string]string `json:"labels"`
|
Labels map[string]string `json:"labels"`
|
||||||
// Proxy is special proxy config for request
|
// Proxy is special proxy config for request
|
||||||
@@ -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")
|
||||||
|
|||||||
+546
-139
File diff suppressed because it is too large.
Load diff
+693
-39
File diff suppressed because it is too large.
Load diff
+137
-89
@@ -3,16 +3,19 @@ package engine
|
|||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
||||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
|
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
|
||||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
|
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/stream"
|
||||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/vm"
|
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/vm"
|
||||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/xhr"
|
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/xhr"
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
"github.com/dop251/goja_nodejs/eventloop"
|
"github.com/dop251/goja_nodejs/eventloop"
|
||||||
gojaurl "github.com/dop251/goja_nodejs/url"
|
gojaurl "github.com/dop251/goja_nodejs/url"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed polyfill/out/index.js
|
//go:embed polyfill/out/index.js
|
||||||
@@ -24,93 +27,118 @@ type Engine struct {
|
|||||||
Runtime *goja.Runtime
|
Runtime *goja.Runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type JSFunction func(goja.FunctionCall) goja.Value
|
||||||
|
|
||||||
// RunString executes the script and returns the go type value
|
// RunString executes the script and returns the go type value
|
||||||
// if script result is promise, it will be resolved
|
// if script result is promise, it will be resolved
|
||||||
func (e *Engine) RunString(script string) (value any, err error) {
|
func (e *Engine) RunString(script string) (value any, err error) {
|
||||||
defer func() {
|
return e.runOnLoop(func(runtime *goja.Runtime) (goja.Value, error) {
|
||||||
if r := recover(); r != nil {
|
return runtime.RunString(script)
|
||||||
err = r.(error)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var result goja.Value
|
|
||||||
e.loop.Run(func(runtime *goja.Runtime) {
|
|
||||||
result, err = runtime.RunString(script)
|
|
||||||
if err == nil {
|
|
||||||
go e.await(result)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return resolveResult(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CallFunction calls the function and returns the go type value
|
// CallFunction calls the function and returns the go type value
|
||||||
// if function result is promise, it will be resolved
|
// if function result is promise, it will be resolved
|
||||||
func (e *Engine) CallFunction(fn goja.Callable, args ...any) (value any, err error) {
|
func (e *Engine) CallFunction(fn any, args ...any) (value any, err error) {
|
||||||
defer func() {
|
return e.runOnLoop(func(runtime *goja.Runtime) (goja.Value, error) {
|
||||||
if r := recover(); r != nil {
|
var jsArgs []goja.Value
|
||||||
err = r.(error)
|
for _, arg := range args {
|
||||||
|
jsArgs = append(jsArgs, runtime.ToValue(arg))
|
||||||
}
|
}
|
||||||
}()
|
switch f := fn.(type) {
|
||||||
|
case goja.Callable:
|
||||||
var result goja.Value
|
if args == nil {
|
||||||
e.loop.Run(func(runtime *goja.Runtime) {
|
return f(nil)
|
||||||
if args == nil {
|
|
||||||
result, err = fn(nil)
|
|
||||||
} else {
|
|
||||||
var jsArgs []goja.Value
|
|
||||||
for _, arg := range args {
|
|
||||||
jsArgs = append(jsArgs, runtime.ToValue(arg))
|
|
||||||
}
|
}
|
||||||
result, err = fn(nil, jsArgs...)
|
return f(nil, jsArgs...)
|
||||||
}
|
case JSFunction:
|
||||||
if err == nil {
|
return callExportedFunction(func(call goja.FunctionCall) goja.Value {
|
||||||
go e.await(result)
|
return f(call)
|
||||||
|
}, jsArgs...)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported function type: %T", fn)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return resolveResult(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// loop.Run will hang if the script result has a non-stop code, such as setInterval.
|
func (e *Engine) runOnLoop(fn func(runtime *goja.Runtime) (goja.Value, error)) (any, error) {
|
||||||
// This method will stop the event loop when the promise result is resolved.
|
type result struct {
|
||||||
func (e *Engine) await(value any) {
|
value any
|
||||||
if value == nil {
|
err error
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
ch := make(chan result, 1)
|
||||||
if v, ok := value.(goja.Value); ok {
|
ok := e.loop.RunOnLoop(func(runtime *goja.Runtime) {
|
||||||
// if result is promise, wait for it to be resolved
|
var once sync.Once
|
||||||
if p, ok := v.Export().(*goja.Promise); ok {
|
sendResult := func(res result) {
|
||||||
if p.State() != goja.PromiseStatePending {
|
once.Do(func() {
|
||||||
|
ch <- res
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
switch v := r.(type) {
|
||||||
|
case error:
|
||||||
|
sendResult(result{err: v})
|
||||||
|
case goja.Value:
|
||||||
|
sendResult(result{err: exportJSError(v)})
|
||||||
|
default:
|
||||||
|
sendResult(result{err: fmt.Errorf("panic: %v", r)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
value, err := fn(runtime)
|
||||||
|
if err != nil {
|
||||||
|
sendResult(result{err: err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p, ok := value.Export().(*goja.Promise); ok {
|
||||||
|
switch p.State() {
|
||||||
|
case goja.PromiseStateFulfilled:
|
||||||
|
sendResult(result{value: exportJSValue(p.Result())})
|
||||||
|
return
|
||||||
|
case goja.PromiseStateRejected:
|
||||||
|
sendResult(result{err: exportJSError(p.Result())})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
promiseObj := value.ToObject(runtime)
|
||||||
// check promise state every 100 milliseconds, until it is resolved
|
thenVal := promiseObj.Get("then")
|
||||||
for {
|
thenFn, ok := goja.AssertFunction(thenVal)
|
||||||
time.Sleep(time.Millisecond * 100)
|
if !ok {
|
||||||
if p.State() == goja.PromiseStatePending {
|
sendResult(result{err: errors.New("promise.then is not callable")})
|
||||||
continue
|
return
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
onFulfilled := runtime.ToValue(func(call goja.FunctionCall) goja.Value {
|
||||||
// stop the event loop
|
sendResult(result{value: exportJSValue(call.Argument(0))})
|
||||||
e.loop.StopNoWait()
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
onRejected := runtime.ToValue(func(call goja.FunctionCall) goja.Value {
|
||||||
|
sendResult(result{err: exportJSError(call.Argument(0))})
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
if _, err := thenFn(promiseObj, onFulfilled, onRejected); err != nil {
|
||||||
|
sendResult(result{err: err})
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
sendResult(result{value: exportJSValue(value)})
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("engine loop terminated")
|
||||||
}
|
}
|
||||||
|
res := <-ch
|
||||||
|
if res.err != nil {
|
||||||
|
return nil, res.err
|
||||||
|
}
|
||||||
|
return res.value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) Close() {
|
func (e *Engine) Close() {
|
||||||
e.loop.StopNoWait()
|
e.loop.Terminate()
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ProxyConfig *base.DownloaderProxyConfig
|
ProxyConfig *base.DownloaderProxyConfig
|
||||||
|
StreamConfig *stream.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEngine(cfg *Config) *Engine {
|
func NewEngine(cfg *Config) *Engine {
|
||||||
@@ -121,7 +149,10 @@ func NewEngine(cfg *Config) *Engine {
|
|||||||
engine := &Engine{
|
engine := &Engine{
|
||||||
loop: loop,
|
loop: loop,
|
||||||
}
|
}
|
||||||
loop.Run(func(runtime *goja.Runtime) {
|
loop.Start()
|
||||||
|
done := make(chan struct{})
|
||||||
|
loop.RunOnLoop(func(runtime *goja.Runtime) {
|
||||||
|
defer close(done)
|
||||||
engine.Runtime = runtime
|
engine.Runtime = runtime
|
||||||
runtime.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
|
runtime.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
|
||||||
vm.Enable(runtime)
|
vm.Enable(runtime)
|
||||||
@@ -153,8 +184,12 @@ func NewEngine(cfg *Config) *Engine {
|
|||||||
if _, err := runtime.RunString("global.location = new URL('http://localhost');"); err != nil {
|
if _, err := runtime.RunString("global.location = new URL('http://localhost');"); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := stream.Enable(runtime, loop, cfg.StreamConfig); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
return
|
return
|
||||||
})
|
})
|
||||||
|
<-done
|
||||||
return engine
|
return engine
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,32 +198,45 @@ func Run(script string) (value any, err error) {
|
|||||||
return engine.RunString(script)
|
return engine.RunString(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the value is Promise, it will be resolved and return the result.
|
func exportJSValue(value goja.Value) any {
|
||||||
func resolveResult(value goja.Value) (any, error) {
|
if value == nil {
|
||||||
export := value.Export()
|
return nil
|
||||||
switch export.(type) {
|
}
|
||||||
case *goja.Promise:
|
return value.Export()
|
||||||
p := export.(*goja.Promise)
|
}
|
||||||
switch p.State() {
|
|
||||||
case goja.PromiseStatePending:
|
func exportJSError(value goja.Value) error {
|
||||||
return nil, nil
|
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||||
case goja.PromiseStateFulfilled:
|
return errors.New("promise rejected")
|
||||||
return p.Result().Export(), nil
|
}
|
||||||
case goja.PromiseStateRejected:
|
if err, ok := value.Export().(error); ok {
|
||||||
if err, ok := p.Result().Export().(error); ok {
|
return err
|
||||||
return nil, err
|
}
|
||||||
} else {
|
stack := value.String()
|
||||||
stack := p.Result().String()
|
if ro, ok := value.(*goja.Object); ok {
|
||||||
result := p.Result()
|
stackVal := ro.Get("stack")
|
||||||
if ro, ok := result.(*goja.Object); ok {
|
if stackVal != nil && stackVal.String() != "" {
|
||||||
stackVal := ro.Get("stack")
|
stack = stackVal.String()
|
||||||
if stackVal != nil && stackVal.String() != "" {
|
|
||||||
stack = stackVal.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, errors.New(stack)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return export, nil
|
return errors.New(stack)
|
||||||
|
}
|
||||||
|
|
||||||
|
func callExportedFunction(fn func(goja.FunctionCall) goja.Value, args ...goja.Value) (ret goja.Value, err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
switch v := r.(type) {
|
||||||
|
case error:
|
||||||
|
err = v
|
||||||
|
case goja.Value:
|
||||||
|
err = exportJSError(v)
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("panic: %v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return fn(goja.FunctionCall{
|
||||||
|
This: nil,
|
||||||
|
Arguments: args,
|
||||||
|
}), nil
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,11 @@ func TestPolyfill(t *testing.T) {
|
|||||||
doTestPolyfill(t, "MessageError")
|
doTestPolyfill(t, "MessageError")
|
||||||
doTestPolyfill(t, "XMLHttpRequest")
|
doTestPolyfill(t, "XMLHttpRequest")
|
||||||
doTestPolyfill(t, "Blob")
|
doTestPolyfill(t, "Blob")
|
||||||
|
doTestPolyfill(t, "Event")
|
||||||
|
doTestPolyfill(t, "EventTarget")
|
||||||
doTestPolyfill(t, "FormData")
|
doTestPolyfill(t, "FormData")
|
||||||
|
doTestPolyfill(t, "AbortController")
|
||||||
|
doTestPolyfill(t, "AbortSignal")
|
||||||
doTestPolyfill(t, "TextDecoder")
|
doTestPolyfill(t, "TextDecoder")
|
||||||
doTestPolyfill(t, "TextEncoder")
|
doTestPolyfill(t, "TextEncoder")
|
||||||
doTestPolyfill(t, "fetch")
|
doTestPolyfill(t, "fetch")
|
||||||
@@ -36,13 +40,75 @@ func TestPolyfill(t *testing.T) {
|
|||||||
func TestError(t *testing.T) {
|
func TestError(t *testing.T) {
|
||||||
engine := NewEngine(nil)
|
engine := NewEngine(nil)
|
||||||
_, err := engine.RunString(`
|
_, err := engine.RunString(`
|
||||||
throw new MessageError('test');
|
throw new MessageError('test');
|
||||||
`)
|
`)
|
||||||
if me, ok := gojautil.AssertError[*gojaerror.MessageError](err); !ok {
|
if me, ok := gojautil.AssertError[*gojaerror.MessageError](err); !ok {
|
||||||
t.Fatalf("expect MessageError, but got %v", me)
|
t.Fatalf("expect MessageError, but got %v", me)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCallFunction_DetachedAsyncWorkDoesNotBlockReturn(t *testing.T) {
|
||||||
|
engine := NewEngine(nil)
|
||||||
|
defer engine.Close()
|
||||||
|
if _, err := engine.RunString(`
|
||||||
|
globalThis.__done = false;
|
||||||
|
async function testDetached() {
|
||||||
|
(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
globalThis.__done = true;
|
||||||
|
})();
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fn, ok := goja.AssertFunction(engine.Runtime.Get("testDetached"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("testDetached is not callable")
|
||||||
|
}
|
||||||
|
startedAt := time.Now()
|
||||||
|
value, err := engine.CallFunction(fn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
elapsed := time.Since(startedAt)
|
||||||
|
if elapsed > 200*time.Millisecond {
|
||||||
|
t.Fatalf("detached async work blocked return for %s", elapsed)
|
||||||
|
}
|
||||||
|
if value != "ok" {
|
||||||
|
t.Fatalf("unexpected return value: %#v", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallFunction_AwaitedPromiseBlocksUntilSettled(t *testing.T) {
|
||||||
|
engine := NewEngine(nil)
|
||||||
|
defer engine.Close()
|
||||||
|
if _, err := engine.RunString(`
|
||||||
|
async function testAwaited() {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fn, ok := goja.AssertFunction(engine.Runtime.Get("testAwaited"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("testAwaited is not callable")
|
||||||
|
}
|
||||||
|
startedAt := time.Now()
|
||||||
|
value, err := engine.CallFunction(fn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
elapsed := time.Since(startedAt)
|
||||||
|
if elapsed < 100*time.Millisecond {
|
||||||
|
t.Fatalf("awaited promise returned too early: %s", elapsed)
|
||||||
|
}
|
||||||
|
if value != "ok" {
|
||||||
|
t.Fatalf("unexpected return value: %#v", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFetch(t *testing.T) {
|
func TestFetch(t *testing.T) {
|
||||||
server := startServer()
|
server := startServer()
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
@@ -64,6 +130,50 @@ async function testText(){
|
|||||||
return await resp.text();
|
return await resp.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testResponseBodyGetReader() {
|
||||||
|
const resp = await fetch(host+'/text', {
|
||||||
|
method: 'POST',
|
||||||
|
body: 'stream-body'
|
||||||
|
});
|
||||||
|
if (!resp.body || typeof resp.body.getReader !== 'function') {
|
||||||
|
throw new Error('response.body.getReader is not available');
|
||||||
|
}
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let result = '';
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
result += decoder.decode(value, { stream: true });
|
||||||
|
}
|
||||||
|
result += decoder.decode();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testStreamingFetchResponseBody() {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const resp = await fetch(host+'/stream');
|
||||||
|
const resolvedAt = Date.now() - startedAt;
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const first = await reader.read();
|
||||||
|
const firstAt = Date.now() - startedAt;
|
||||||
|
const second = await reader.read();
|
||||||
|
const secondAt = Date.now() - startedAt;
|
||||||
|
let result = '';
|
||||||
|
result += decoder.decode(first.value, { stream: true });
|
||||||
|
result += decoder.decode(second.value, { stream: true });
|
||||||
|
result += decoder.decode();
|
||||||
|
return {
|
||||||
|
resolvedAt,
|
||||||
|
firstAt,
|
||||||
|
secondAt,
|
||||||
|
text: result
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function testOctetStream(file){
|
async function testOctetStream(file){
|
||||||
const resp = await fetch(host+'/octetStream',{
|
const resp = await fetch(host+'/octetStream',{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -251,6 +361,32 @@ async function testFingerprintSafari(){
|
|||||||
t.Fatalf("testText failed, want %s, got %s", "test", result)
|
t.Fatalf("testText failed, want %s, got %s", "test", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result, err = callTestFun(engine, "testResponseBodyGetReader")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result != "stream-body" {
|
||||||
|
t.Fatalf("testResponseBodyGetReader failed, want %s, got %s", "stream-body", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = callTestFun(engine, "testStreamingFetchResponseBody")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
streaming := result.(map[string]any)
|
||||||
|
if streaming["text"] != "chunk-1chunk-2" {
|
||||||
|
t.Fatalf("testStreamingFetchResponseBody failed, want %s, got %v", "chunk-1chunk-2", streaming["text"])
|
||||||
|
}
|
||||||
|
if int(streaming["resolvedAt"].(int64)) >= 120 {
|
||||||
|
t.Fatalf("fetch resolved too late, got %dms", streaming["resolvedAt"].(int64))
|
||||||
|
}
|
||||||
|
if int(streaming["firstAt"].(int64)) >= 120 {
|
||||||
|
t.Fatalf("first chunk arrived too late, got %dms", streaming["firstAt"].(int64))
|
||||||
|
}
|
||||||
|
if int(streaming["secondAt"].(int64)) < 120 {
|
||||||
|
t.Fatalf("second chunk arrived too early, got %dms", streaming["secondAt"].(int64))
|
||||||
|
}
|
||||||
|
|
||||||
func() {
|
func() {
|
||||||
jsFile, _, md5 := buildFile(t, engine.Runtime)
|
jsFile, _, md5 := buildFile(t, engine.Runtime)
|
||||||
result, err = callTestFun(engine, "testOctetStream", jsFile)
|
result, err = callTestFun(engine, "testOctetStream", jsFile)
|
||||||
@@ -464,6 +600,18 @@ func startServer() net.Listener {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write(buf)
|
w.Write(buf)
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("/stream", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if flusher, ok := w.(http.Flusher); ok {
|
||||||
|
_, _ = w.Write([]byte("chunk-1"))
|
||||||
|
flusher.Flush()
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
_, _ = w.Write([]byte("chunk-2"))
|
||||||
|
flusher.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte("chunk-1chunk-2"))
|
||||||
|
})
|
||||||
mux.HandleFunc("/octetStream", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/octetStream", func(w http.ResponseWriter, r *http.Request) {
|
||||||
md5 := calcMd5(r.Body)
|
md5 := calcMd5(r.Body)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,278 @@
|
|||||||
|
package stream_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
type createdObjectURL struct {
|
||||||
|
opts *stream.ObjectURLOptions
|
||||||
|
open stream.ObjectURLOpener
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseBlobObjectURLReadsResponseStream(t *testing.T) {
|
||||||
|
const payload = "response blob payload"
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
_, _ = io.WriteString(w, payload[:8])
|
||||||
|
w.(http.Flusher).Flush() // force an unknown-length/chunked response
|
||||||
|
_, _ = io.WriteString(w, payload[8:])
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
created := make(chan createdObjectURL, 1)
|
||||||
|
engine, cleanup := newStreamTestEngine(t, created)
|
||||||
|
defer func() {
|
||||||
|
cleanup()
|
||||||
|
engine.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
value, err := engine.RunString(fmt.Sprintf(`
|
||||||
|
(async () => {
|
||||||
|
const response = await fetch(%q);
|
||||||
|
const blob = await response.blob();
|
||||||
|
return __gopeed_blob_create_object_url(blob);
|
||||||
|
})()
|
||||||
|
`, server.URL))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value != "blob:test-1" {
|
||||||
|
t.Fatalf("unexpected object URL: %#v", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
object := <-created
|
||||||
|
if object.opts.Size != int64(len(payload)) || object.opts.ContentType != "text/plain" {
|
||||||
|
t.Fatalf("unexpected object URL options: %#v", object.opts)
|
||||||
|
}
|
||||||
|
if !object.opts.Range {
|
||||||
|
t.Fatal("materialized response Blob should advertise range support")
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
reader, err := object.open(context.Background(), stream.ObjectURLOpenRequest{Offset: 0, End: -1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, readErr := io.ReadAll(reader)
|
||||||
|
_ = reader.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatal(readErr)
|
||||||
|
}
|
||||||
|
if got := string(data); got != payload {
|
||||||
|
t.Fatalf("unexpected response Blob data on open %d: %q", i+1, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rangeReader, err := object.open(context.Background(), stream.ObjectURLOpenRequest{Offset: 9, End: 12})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rangeData, readErr := io.ReadAll(rangeReader)
|
||||||
|
_ = rangeReader.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatal(readErr)
|
||||||
|
}
|
||||||
|
if got, want := string(rangeData), payload[9:13]; got != want {
|
||||||
|
t.Fatalf("unexpected materialized Blob range: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseBlobRejectsTruncatedResponse(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Length", "64")
|
||||||
|
_, _ = io.WriteString(w, "partial")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
created := make(chan createdObjectURL, 1)
|
||||||
|
engine, cleanup := newStreamTestEngine(t, created)
|
||||||
|
defer func() {
|
||||||
|
cleanup()
|
||||||
|
engine.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
value, err := engine.RunString(fmt.Sprintf(`
|
||||||
|
(async () => {
|
||||||
|
const response = await fetch(%q);
|
||||||
|
try {
|
||||||
|
await response.blob();
|
||||||
|
return "resolved";
|
||||||
|
} catch (_) {
|
||||||
|
return "rejected";
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
`, server.URL))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value != "rejected" {
|
||||||
|
t.Fatalf("truncated response unexpectedly materialized a Blob: %#v", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyBlobObjectURL(t *testing.T) {
|
||||||
|
created := make(chan createdObjectURL, 1)
|
||||||
|
engine, cleanup := newStreamTestEngine(t, created)
|
||||||
|
defer func() {
|
||||||
|
cleanup()
|
||||||
|
engine.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
value, err := engine.RunString(`
|
||||||
|
__gopeed_blob_create_object_url(new Blob([], { type: "application/empty" }))
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value != "blob:test-1" {
|
||||||
|
t.Fatalf("unexpected object URL: %#v", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
object := <-created
|
||||||
|
if object.opts.Size != 0 || object.opts.Range {
|
||||||
|
t.Fatalf("unexpected empty Blob options: %#v", object.opts)
|
||||||
|
}
|
||||||
|
reader, err := object.open(context.Background(), stream.ObjectURLOpenRequest{Offset: 0, End: -1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(data) != 0 {
|
||||||
|
t.Fatalf("expected empty Blob, got %q", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlobObjectURLReaderCloseCancelsPendingJSReader(t *testing.T) {
|
||||||
|
created := make(chan createdObjectURL, 1)
|
||||||
|
engine, cleanup := newStreamTestEngine(t, created)
|
||||||
|
defer func() {
|
||||||
|
cleanup()
|
||||||
|
engine.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
value, err := engine.RunString(`
|
||||||
|
globalThis.__blobPipeCancelState = {
|
||||||
|
readCalls: 0,
|
||||||
|
cancelCalls: 0,
|
||||||
|
releaseCalls: 0,
|
||||||
|
cancelReason: "",
|
||||||
|
resolveRead: null,
|
||||||
|
};
|
||||||
|
const pendingSource = {
|
||||||
|
getReader() {
|
||||||
|
return {
|
||||||
|
read() {
|
||||||
|
__blobPipeCancelState.readCalls++;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
__blobPipeCancelState.resolveRead = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel(reason) {
|
||||||
|
__blobPipeCancelState.cancelCalls++;
|
||||||
|
__blobPipeCancelState.cancelReason = String(reason);
|
||||||
|
if (__blobPipeCancelState.resolveRead) {
|
||||||
|
__blobPipeCancelState.resolveRead({ done: true, value: undefined });
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
releaseLock() {
|
||||||
|
__blobPipeCancelState.releaseCalls++;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
__gopeed_blob_create_object_url(async () => pendingSource);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value != "blob:test-1" {
|
||||||
|
t.Fatalf("unexpected object URL: %#v", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
object := <-created
|
||||||
|
reader, err := object.open(context.Background(), stream.ObjectURLOpenRequest{Offset: 0, End: -1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
readDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, readErr := reader.Read(make([]byte, 1))
|
||||||
|
readDone <- readErr
|
||||||
|
}()
|
||||||
|
|
||||||
|
waitForJSValue(t, engine, `String(__blobPipeCancelState.readCalls)`, "1")
|
||||||
|
if err := reader.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := reader.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case readErr := <-readDone:
|
||||||
|
if !errors.Is(readErr, context.Canceled) {
|
||||||
|
t.Fatalf("unexpected pending read result: %v", readErr)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("pending Go read was not released by Close")
|
||||||
|
}
|
||||||
|
waitForJSValue(t, engine, `JSON.stringify({
|
||||||
|
cancelCalls: __blobPipeCancelState.cancelCalls,
|
||||||
|
releaseCalls: __blobPipeCancelState.releaseCalls,
|
||||||
|
cancelReason: __blobPipeCancelState.cancelReason,
|
||||||
|
})`, `{"cancelCalls":1,"releaseCalls":1,"cancelReason":"blob request closed"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForJSValue(t *testing.T, engine *engine.Engine, expression, want string) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
var got any
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
var err error
|
||||||
|
got, err = engine.RunString(expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("timed out waiting for JavaScript value %q, got %#v", want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStreamTestEngine(t *testing.T, created chan<- createdObjectURL) (*engine.Engine, func()) {
|
||||||
|
t.Helper()
|
||||||
|
var next atomic.Int64
|
||||||
|
var cleanup func()
|
||||||
|
e := engine.NewEngine(&engine.Config{
|
||||||
|
StreamConfig: &stream.Config{
|
||||||
|
CreateObjectURL: func(opts *stream.ObjectURLOptions, open stream.ObjectURLOpener) (string, error) {
|
||||||
|
created <- createdObjectURL{opts: opts, open: open}
|
||||||
|
return fmt.Sprintf("blob:test-%d", next.Add(1)), nil
|
||||||
|
},
|
||||||
|
RevokeObjectURL: func(string) error { return nil },
|
||||||
|
RegisterCleanup: func(fn func()) {
|
||||||
|
cleanup = fn
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if cleanup == nil {
|
||||||
|
t.Fatal("stream cleanup was not registered")
|
||||||
|
}
|
||||||
|
return e, cleanup
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package stream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExportFetchRequestSnapshotsArrayBuffer(t *testing.T) {
|
||||||
|
runtime := goja.New()
|
||||||
|
buffer := runtime.NewArrayBuffer([]byte("before"))
|
||||||
|
request := runtime.NewObject()
|
||||||
|
setFetchRequestDefaults(t, request)
|
||||||
|
if err := request.Set("body", buffer); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := exportFetchRequest(runtime, request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
buffer.Bytes()[0] = 'X'
|
||||||
|
|
||||||
|
body, ok := meta.Body.([]byte)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected byte snapshot, got %T", meta.Body)
|
||||||
|
}
|
||||||
|
if got := string(body); got != "before" {
|
||||||
|
t.Fatalf("ArrayBuffer snapshot changed after export: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportFetchRequestSnapshotsFormData(t *testing.T) {
|
||||||
|
runtime := goja.New()
|
||||||
|
runtime.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
|
||||||
|
if err := formdata.Enable(runtime); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
value, err := runtime.RunString(`
|
||||||
|
globalThis.form = new FormData();
|
||||||
|
form.append("field", "before");
|
||||||
|
form;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := runtime.NewObject()
|
||||||
|
setFetchRequestDefaults(t, request)
|
||||||
|
if err := request.Set("body", value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := exportFetchRequest(runtime, request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := runtime.RunString(`form.set("field", "after")`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, ok := meta.Body.(*fetchFormDataSnapshot)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected FormData snapshot, got %T", meta.Body)
|
||||||
|
}
|
||||||
|
if len(body.entries) != 1 {
|
||||||
|
t.Fatalf("expected one FormData entry, got %d", len(body.entries))
|
||||||
|
}
|
||||||
|
if got := body.entries[0]; got.name != "field" || got.value != "before" {
|
||||||
|
t.Fatalf("unexpected FormData snapshot: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setFetchRequestDefaults(t *testing.T, request *goja.Object) {
|
||||||
|
t.Helper()
|
||||||
|
for key, value := range map[string]any{
|
||||||
|
"url": "https://example.test",
|
||||||
|
"method": "POST",
|
||||||
|
"redirect": "follow",
|
||||||
|
"credentials": "same-origin",
|
||||||
|
} {
|
||||||
|
if err := request.Set(key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotFetchBodyCopiesFileMetadata(t *testing.T) {
|
||||||
|
source := &file.File{
|
||||||
|
Reader: strings.NewReader("payload"),
|
||||||
|
Name: "before.txt",
|
||||||
|
Size: 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := snapshotFetchBody(source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshot, ok := value.(*file.File)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected file snapshot, got %T", value)
|
||||||
|
}
|
||||||
|
source.Name = "after.txt"
|
||||||
|
source.Size = 99
|
||||||
|
|
||||||
|
if snapshot.Name != "before.txt" || snapshot.Size != 7 {
|
||||||
|
t.Fatalf("file metadata was not snapshotted: %#v", snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRegistryCloseAll(t *testing.T) {
|
||||||
|
registry := newFetchRegistry()
|
||||||
|
streamCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
body := &trackingReadCloser{}
|
||||||
|
registry.streams["active"] = &fetchStream{
|
||||||
|
body: body,
|
||||||
|
cancel: cancel,
|
||||||
|
ctx: streamCtx,
|
||||||
|
ch: make(chan fetchChunk),
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.CloseAll()
|
||||||
|
registry.CloseAll()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-streamCtx.Done():
|
||||||
|
default:
|
||||||
|
t.Fatal("active fetch context was not canceled")
|
||||||
|
}
|
||||||
|
if got := body.closeCalls.Load(); got != 1 {
|
||||||
|
t.Fatalf("expected body to close once, got %d", got)
|
||||||
|
}
|
||||||
|
registry.mu.Lock()
|
||||||
|
closed := registry.closed
|
||||||
|
remaining := len(registry.streams)
|
||||||
|
registry.mu.Unlock()
|
||||||
|
if !closed || remaining != 0 {
|
||||||
|
t.Fatalf("registry not fully closed: closed=%v remaining=%d", closed, remaining)
|
||||||
|
}
|
||||||
|
if _, err := registry.Open("", nil, &fetchRequest{URL: "https://example.test"}); err == nil {
|
||||||
|
t.Fatal("expected a closed registry to reject new fetches")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRegistryReadPreservesChunkOrder(t *testing.T) {
|
||||||
|
registry := newFetchRegistry()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
stream := &fetchStream{
|
||||||
|
body: io.NopCloser(strings.NewReader("")),
|
||||||
|
cancel: cancel,
|
||||||
|
ctx: ctx,
|
||||||
|
ch: make(chan fetchChunk, 2),
|
||||||
|
}
|
||||||
|
stream.ch <- fetchChunk{data: []byte("abc")}
|
||||||
|
stream.ch <- fetchChunk{data: []byte("def")}
|
||||||
|
registry.streams["ordered"] = stream
|
||||||
|
defer registry.CloseAll()
|
||||||
|
|
||||||
|
var got strings.Builder
|
||||||
|
for range 4 {
|
||||||
|
chunk, done, err := registry.Read("ordered", 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if done {
|
||||||
|
t.Fatal("stream ended before buffered chunks were read")
|
||||||
|
}
|
||||||
|
got.Write(chunk)
|
||||||
|
}
|
||||||
|
if got.String() != "abcdef" {
|
||||||
|
t.Fatalf("fetch chunks were reordered: %q", got.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type trackingReadCloser struct {
|
||||||
|
closeCalls atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *trackingReadCloser) Read([]byte) (int, error) {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *trackingReadCloser) Close() error {
|
||||||
|
r.closeCalls.Add(1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,8 @@
|
|||||||
package vm
|
package vm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
"github.com/dop251/goja_nodejs/eventloop"
|
"github.com/dop251/goja_nodejs/eventloop"
|
||||||
)
|
)
|
||||||
@@ -25,7 +27,12 @@ func (vm *Vm) Get(name string) (value any) {
|
|||||||
func (vm *Vm) RunString(script string) (value any, err error) {
|
func (vm *Vm) RunString(script string) (value any, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
err = r.(error)
|
switch v := r.(type) {
|
||||||
|
case error:
|
||||||
|
err = v
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("panic: %v", r)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
(()=>{var e={725:function(e,t,r){var o,n,i;i="undefined"!=typeof self&&self||"undefined"!=typeof window&&window||void 0!==r.g&&r.g||this,o=function(e){"use strict";var t=i.BlobBuilder||i.WebKitBlobBuilder||i.MSBlobBuilder||i.MozBlobBuilder,r=i.URL||i.webkitURL||function(e,t){return(t=document.createElement("a")).href=e,t},o=i.Blob,n=r.createObjectURL,a=r.revokeObjectURL,s=i.Symbol&&i.Symbol.toStringTag,u=!1,f=!1,l=t&&t.prototype.append&&t.prototype.getBlob;try{u=2===new Blob(["ä"]).size,f=2===new Blob([new Uint8Array([1,2])]).size}catch(e){}function c(e){return e.map((function(e){if(e.buffer instanceof ArrayBuffer){var t=e.buffer;if(e.byteLength!==t.byteLength){var r=new Uint8Array(e.byteLength);r.set(new Uint8Array(t,e.byteOffset,e.byteLength)),t=r.buffer}return t}return e}))}function h(e,r){r=r||{};var o=new t;return c(e).forEach((function(e){o.append(e)})),r.type?o.getBlob(r.type):o.getBlob()}function d(e,t){return new o(c(e),t||{})}i.Blob&&(h.prototype=Blob.prototype,d.prototype=Blob.prototype);var p="function"==typeof TextEncoder?TextEncoder.prototype.encode.bind(new TextEncoder):function(e){for(var t=0,r=e.length,o=i.Uint8Array||Array,n=0,a=Math.max(32,r+(r>>1)+7),s=new o(a>>3<<3);t<r;){var u=e.charCodeAt(t++);if(u>=55296&&u<=56319){if(t<r){var f=e.charCodeAt(t);56320==(64512&f)&&(++t,u=((1023&u)<<10)+(1023&f)+65536)}if(u>=55296&&u<=56319)continue}if(n+4>s.length){a+=8,a=(a*=1+t/e.length*2)>>3<<3;var l=new Uint8Array(a);l.set(s),s=l}if(0!=(4294967168&u)){if(0==(4294965248&u))s[n++]=u>>6&31|192;else if(0==(4294901760&u))s[n++]=u>>12&15|224,s[n++]=u>>6&63|128;else{if(0!=(4292870144&u))continue;s[n++]=u>>18&7|240,s[n++]=u>>12&63|128,s[n++]=u>>6&63|128}s[n++]=63&u|128}else s[n++]=u}return s.slice(0,n)},y="function"==typeof TextDecoder?TextDecoder.prototype.decode.bind(new TextDecoder):function(e){for(var t=e.length,r=[],o=0;o<t;){var n,i,a,s,u=e[o],f=null,l=u>239?4:u>223?3:u>191?2:1;if(o+l<=t)switch(l){case 1:u<128&&(f=u);break;case 2:128==(192&(n=e[o+1]))&&(s=(31&u)<<6|63&n)>127&&(f=s);break;case 3:n=e[o+1],i=e[o+2],128==(192&n)&&128==(192&i)&&(s=(15&u)<<12|(63&n)<<6|63&i)>2047&&(s<55296||s>57343)&&(f=s);break;case 4:n=e[o+1],i=e[o+2],a=e[o+3],128==(192&n)&&128==(192&i)&&128==(192&a)&&(s=(15&u)<<18|(63&n)<<12|(63&i)<<6|63&a)>65535&&s<1114112&&(f=s)}null===f?(f=65533,l=1):f>65535&&(f-=65536,r.push(f>>>10&1023|55296),f=56320|1023&f),r.push(f),o+=l}for(var c=r.length,h="",d=0;d<c;)h+=String.fromCharCode.apply(String,r.slice(d,d+=4096));return h};function b(){var t=!!i.ActiveXObject||"-ms-scroll-limit"in document.documentElement.style&&"-ms-ime-align"in document.documentElement.style,r=i.XMLHttpRequest&&i.XMLHttpRequest.prototype.send;t&&r&&(XMLHttpRequest.prototype.send=function(e){e instanceof Blob?(this.setRequestHeader("Content-Type",e.type),r.call(this,e)):r.call(this,e)});try{new File([],""),e.File=i.File,e.FileReader=i.FileReader}catch(t){try{e.File=new Function('class File extends Blob {constructor(chunks, name, opts) {opts = opts || {};super(chunks, opts || {});this.name = name.replace(/\\//g, ":");this.lastModifiedDate = opts.lastModified ? new Date(opts.lastModified) : new Date();this.lastModified = +this.lastModifiedDate;}};return new File([], ""), File')()}catch(t){e.File=function(e,t,r){var o=new Blob(e,r),n=r&&void 0!==r.lastModified?new Date(r.lastModified):new Date;return o.name=t.replace(/\//g,":"),o.lastModifiedDate=n,o.lastModified=+n,o.toString=function(){return"[object File]"},s&&(o[s]="File"),o}}}}u?(b(),e.Blob=f?i.Blob:d):l?(b(),e.Blob=h):function(){function t(e){for(var t=new Array(e.byteLength),r=new Uint8Array(e),o=t.length;o--;)t[o]=r[o];return t}function o(e){for(var t="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",r=[],o=0;o<e.length;o+=3){var n=e[o],i=o+1<e.length,a=i?e[o+1]:0,s=o+2<e.length,u=s?e[o+2]:0,f=n>>2,l=(3&n)<<4|a>>4,c=(15&a)<<2|u>>6,h=63&u;s||(h=64,i||(c=64)),r.push(t[f],t[l],t[c],t[h])}return r.join("")}var s=Object.create||function(e){function t(){}return t.prototype=e,new t};function u(e){return Object.prototype.toString.call(e).slice(8,-1)}function f(e,t){return"object"==typeof e&&Object.prototype.isPrototypeOf.call(e.prototype,t)}var l=["Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Uint16Array","Int32Array","Uint32Array","Float32Array","Float64Array","ArrayBuffer"];function c(e){return t=l,r=u(e),-1!==t.indexOf(r)||f(i.ArrayBuffer,e);var t,r}function h(e,r){r=null==r?{}:r;for(var o=0,n=(e=e?e.slice():[]).length;o<n;o++){var a=e[o];a instanceof h?e[o]=a._buffer:"string"==typeof a?e[o]=p(a):"DataView"===u(s=a)||f(i.DataView,s)?e[o]=t(a.buffer):c(a)?e[o]=t(a):e[o]=p(String(a))}var s;this._buffer=i.Uint8Array?function(e){for(var t=0,r=e.length;r--;)t+=e[r].length;for(var o=new Uint8Array(t),n=0,i=0;i<e.length;i++){var a=e[i];o.set(a,n),n+=a.byteLength||a.length}return o}(e):[].concat.apply([],e),this.size=this._buffer.length,this.type=r.type||"",/[^\u0020-\u007E]/.test(this.type)?this.type="":this.type=this.type.toLowerCase()}funcLine truncated
|
(()=>{var e={847(e,t,r){var n,o;n=function(){"use strict";function e(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=Array(t);r<t;r++)n[r]=e[r];return n}function t(e,t,r){return t=l(t),function(e,t){if(t&&("object"==typeof t||"function"==typeof t))return t;if(void 0!==t)throw new TypeError("Derived constructors may only return object or undefined");return function(e){if(void 0===e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return e}(e)}(e,c()?Reflect.construct(t,r||[],l(e).constructor):t.apply(e,r))}function n(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function o(e,t){for(var r=0;r<t.length;r++){var n=t[r];n.enumerable=n.enumerable||!1,n.configurable=!0,"value"in n&&(n.writable=!0),Object.defineProperty(e,f(n.key),n)}}function i(e,t,r){return t&&o(e.prototype,t),r&&o(e,r),Object.defineProperty(e,"prototype",{writable:!1}),e}function a(t,r){var n="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(!n){if(Array.isArray(t)||(n=function(t,r){if(t){if("string"==typeof t)return e(t,r);var n={}.toString.call(t).slice(8,-1);return"Object"===n&&t.constructor&&(n=t.constructor.name),"Map"===n||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?e(t,r):void 0}}(t))||r&&t&&"number"==typeof t.length){n&&(t=n);var o=0,i=function(){};return{s:i,n:function(){return o>=t.length?{done:!0}:{done:!1,value:t[o++]}},e:function(e){throw e},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var a,s=!0,l=!1;return{s:function(){n=n.call(t)},n:function(){var e=n.next();return s=e.done,e},e:function(e){l=!0,a=e},f:function(){try{s||null==n.return||n.return()}finally{if(l)throw a}}}}function s(){return s="undefined"!=typeof Reflect&&Reflect.get?Reflect.get.bind():function(e,t,r){var n=function(e,t){for(;!{}.hasOwnProperty.call(e,t)&&null!==(e=l(e)););return e}(e,t);if(n){var o=Object.getOwnPropertyDescriptor(n,t);return o.get?o.get.call(arguments.length<3?e:r):o.value}},s.apply(null,arguments)}function l(e){return l=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(e){return e.__proto__||Object.getPrototypeOf(e)},l(e)}function c(){try{var e=!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],function(){}))}catch(e){}return(c=function(){return!!e})()}function u(e,t){return u=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},u(e,t)}function f(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var n=r.call(e,t||"default");if("object"!=typeof n)return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}!function(e){e.AbortSignal,e.AbortController}("undefined"!=typeof self?self:r.g);var p=function(){return i(function e(){n(this,e),Object.defineProperty(this,"listeners",{value:{},writable:!0,configurable:!0})},[{key:"addEventListener",value:function(e,t,r){e in this.listeners||(this.listeners[e]=[]),this.listeners[e].push({callback:t,options:r})}},{key:"removeEventListener",value:function(e,t){if(e in this.listeners)for(var r=this.listeners[e],n=0,o=r.length;n<o;n++)if(r[n].callback===t)return void r.splice(n,1)}},{key:"dispatchEvent",value:function(e){var t=this;if(e.type in this.listeners){for(var r=this.listeners[e.type].slice(),n=function(){var n=r[o];try{n.callback.call(t,e)}catch(e){Promise.resolve().then(function(){throw e})}n.options&&n.options.once&&t.removeEventListener(e.type,n.callback)},o=0,i=r.length;o<i;o++)n();return!e.defaultPrevented}}}])}(),d=function(e){function r(){var e;return n(this,r),(e=t(this,r)).listeners||p.call(e),Object.defineProperty(e,"aborted",{value:!1,writable:!0,configurable:!0}),Object.defineProperty(e,"onabort",{value:null,writable:!0,configurable:!0}),Object.defineProperty(e,"reason",{value:void 0,writable:!0,configurable:!0}),e}return function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function");e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,writable:!0,configurable:!0}}),Object.defineProperty(e,"prototype",{writable:!1}),t&&u(e,t)}(r,e),i(r,[{key:"toString",value:function(){return"[object AbortSignal]"}},{key:"dispatchEvent",value:function(e){var t,n,o,i,a;"abort"===e.type&&(this.aborted=!0,"function"==typeof this.onabort&&this.onabort.call(this,e)),(t=r,n="dispatchEvent",o=this,a=s(l(1&(i=3)?t.prototype:t),n,o),2&i&&"function"==typeof a?function(e){return a.apply(o,e)}:a)([e])}},{key:"throwIfAborted",value:function(){var e=this.aborted,t=this.reason;if(e)throw void 0===t?"Aborted":t}}],[{key:"timeout",value:function(e){var t=new h;return setTimeout(function(){return t.abort(new DOMException("This signal is timeout in ".concat(e,"ms"),"TimeoutError"))},e),t.signal}},{keLine truncated
|
||||||
Loaded 50 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user