mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-13 11:53:41 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfda439b82 | ||
|
|
c2ada7dbaa | ||
|
|
6f0cfdcd91 | ||
|
|
fdfc6902a7 | ||
|
|
9fb3758203 | ||
|
|
e2ac8ce64b | ||
|
|
c01d11305c | ||
|
|
def54f8998 | ||
|
|
a205c0b8d7 | ||
|
|
ae70dc240c | ||
|
|
d7f6c3b206 | ||
|
|
a83e38dac4 | ||
|
|
d46c06f08d | ||
|
|
a61cdbed67 |
No files matched your search
+13
-11
@@ -13,8 +13,8 @@ on:
|
||||
default: "false"
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.24"
|
||||
FLUTTER_VERSION: "3.41.2"
|
||||
GO_VERSION: "1.25"
|
||||
FLUTTER_VERSION: "3.41.6"
|
||||
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
|
||||
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
||||
$b >> $env:GITHUB_PATH
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
@@ -198,7 +198,7 @@ jobs:
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- name: Build
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
needs: [get-release, build-macos-amd64-lib]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: actions/setup-node@v3
|
||||
@@ -315,7 +315,7 @@ jobs:
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
@@ -324,7 +324,7 @@ jobs:
|
||||
channel: master
|
||||
- run: |
|
||||
sudo apt update -y
|
||||
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate 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)
|
||||
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
|
||||
chmod +x appimagetool
|
||||
@@ -411,7 +411,7 @@ jobs:
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- name: Setup LXD
|
||||
@@ -480,6 +480,7 @@ jobs:
|
||||
- cmake
|
||||
- curl
|
||||
- libgtk-3-dev
|
||||
- libwebkit2gtk-4.1-dev
|
||||
- libkeybinder-3.0-dev
|
||||
- ninja-build
|
||||
- unzip
|
||||
@@ -495,6 +496,7 @@ jobs:
|
||||
source: .
|
||||
plugin: nil
|
||||
stage-packages:
|
||||
- libwebkit2gtk-4.1-0
|
||||
- libgtk-3-dev
|
||||
- libkeybinder-3.0-dev
|
||||
- libappindicator3-dev
|
||||
@@ -545,7 +547,7 @@ jobs:
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: actions/setup-java@v3
|
||||
@@ -587,7 +589,7 @@ jobs:
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
@@ -620,7 +622,7 @@ jobs:
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
- "Dockerfile"
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.24"
|
||||
GO_VERSION: "1.25"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: release-drafter/release-drafter@v5
|
||||
|
||||
+71
-15
@@ -28,8 +28,8 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.24"
|
||||
FLUTTER_VERSION: "3.41.2"
|
||||
GO_VERSION: "1.25"
|
||||
FLUTTER_VERSION: "3.41.6"
|
||||
|
||||
jobs:
|
||||
# lint:
|
||||
@@ -50,14 +50,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
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.
|
||||
run: |
|
||||
# Get packages for testing and coverage (including cmd/web for flags_test.go)
|
||||
PACKAGES=$(go list ./... | grep -v /bind/ | grep -v -E '/cmd/(?!web)')
|
||||
go test -v -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $PACKAGES
|
||||
PACKAGES=$(go list ./... | awk '!/\/bind\// && (!/\/cmd\// || /\/cmd\/web$/)')
|
||||
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
|
||||
grep -v "main.go" coverage.txt > coverage_filtered.txt || true
|
||||
mv coverage_filtered.txt coverage.txt
|
||||
@@ -65,6 +69,58 @@ jobs:
|
||||
with:
|
||||
files: ./coverage.txt
|
||||
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:
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -80,7 +136,7 @@ jobs:
|
||||
flutter_channel: "main"
|
||||
name: Build desktop check (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: [test]
|
||||
needs: [ test ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Enable long paths for flutter main branch checks
|
||||
@@ -109,7 +165,7 @@ jobs:
|
||||
"CGO_ENABLED=1" >> $env:GITHUB_ENV
|
||||
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
||||
$b >> $env:GITHUB_PATH
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
@@ -129,7 +185,7 @@ jobs:
|
||||
- if: runner.os == 'Linux'
|
||||
run: |
|
||||
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
|
||||
cd ui/flutter
|
||||
flutter build linux
|
||||
@@ -138,11 +194,11 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-14, ubuntu-latest]
|
||||
needs: [test]
|
||||
os: [ macos-14, ubuntu-latest ]
|
||||
needs: [ test ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
@@ -171,10 +227,10 @@ jobs:
|
||||
build-web:
|
||||
name: Build web check
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
needs: [ test ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
@@ -192,10 +248,10 @@ jobs:
|
||||
build-docker:
|
||||
name: Build docker check
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
needs: [ test ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- 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
|
||||
COPY ./go.mod ./go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
@@ -201,7 +201,7 @@ This project is divided into two parts, the front end uses `flutter`, the back e
|
||||
|
||||
### 🌍 Environment
|
||||
|
||||
1. Golang 1.24+
|
||||
1. Golang 1.25+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 📋 Clone
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
||||
|
||||
### 🌍 環境
|
||||
|
||||
1. Go 言語 1.24+
|
||||
1. Go 言語 1.25+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 📋 クローン
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
1. Golang 1.24+
|
||||
1. Golang 1.25+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 📋 Clone
|
||||
|
||||
+1
-1
@@ -192,7 +192,7 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、
|
||||
|
||||
### 🌍 环境要求
|
||||
|
||||
1. Golang 1.24+
|
||||
1. Golang 1.25+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 📋 克隆项目
|
||||
|
||||
+1
-1
@@ -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+
|
||||
|
||||
### 📋 克隆項目
|
||||
|
||||
@@ -16,6 +16,7 @@ func Start(cfg *C.char) (int, *C.char) {
|
||||
return 0, C.CString(err.Error())
|
||||
}
|
||||
config.ProductionMode = true
|
||||
applyWebViewProvider(&config)
|
||||
realPort, err := rest.Start(&config)
|
||||
if err != nil {
|
||||
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 (
|
||||
"encoding/json"
|
||||
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
@@ -14,9 +15,17 @@ func Start(cfg string) (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
config.ProductionMode = true
|
||||
applyWebViewProvider(&config)
|
||||
return rest.Start(&config)
|
||||
}
|
||||
|
||||
func 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
|
||||
|
||||
go 1.24.9
|
||||
|
||||
toolchain go1.24.11
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5
|
||||
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||
github.com/bodgit/sevenzip v1.6.1
|
||||
@@ -17,6 +16,7 @@ require (
|
||||
github.com/matoous/go-nanoid/v2 v2.0.0
|
||||
github.com/mattn/go-ieproxy v0.0.12
|
||||
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/rs/dnscache v0.0.0-20230804202142-fc85eb664529
|
||||
github.com/rs/zerolog v1.31.0
|
||||
@@ -45,7 +45,6 @@ require (
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.0.1 // indirect
|
||||
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.0 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
|
||||
@@ -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=
|
||||
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/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/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
@@ -386,8 +388,6 @@ 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/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/monkeyWie/goed2k v0.0.0-20260317094144-6e18d43056e5 h1:tEOucyKJzFsCz5Gr41jFHj8i2g1zemTyS4uyErJgFHc=
|
||||
github.com/monkeyWie/goed2k v0.0.0-20260317094144-6e18d43056e5/go.mod h1:Ry2y1QlzerUgA1hVmExBdXXzE4Sjk1M7w0nSh6dhDOg=
|
||||
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=
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -376,7 +376,7 @@ func TestFetcher_DownloadError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetcher_DownloadLimit(t *testing.T) {
|
||||
listener := test.StartTestLimitServer(4, 0)
|
||||
listener := test.StartTestConLimitServer(4)
|
||||
defer listener.Close()
|
||||
|
||||
downloadNormal(listener, 1, t)
|
||||
@@ -614,7 +614,7 @@ func TestFetcher_SlowStartExpansion(t *testing.T) {
|
||||
os.Remove(test.DownloadFile)
|
||||
|
||||
// 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
|
||||
cleanup := func() {
|
||||
@@ -758,7 +758,7 @@ func TestFetcher_AsyncPrefetch(t *testing.T) {
|
||||
t.Run("PrefetchPartial", func(t *testing.T) {
|
||||
// Use slow server with 100 nanosecond delay per byte
|
||||
// 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()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
|
||||
+12
-19
@@ -433,9 +433,8 @@ func StartTestExpiringRedirectServer(requestsBeforeExpire int32, delayPerByte ti
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestSlowStartServer creates a server with configurable delay per request
|
||||
// This allows testing slow-start connection expansion to reach max connections
|
||||
func StartTestSlowStartServer(delayPerByte time.Duration) net.Listener {
|
||||
// StartTestLowSpeedServer creates a server with configurable delay per request
|
||||
func StartTestLowSpeedServer(delayPerByte time.Duration) net.Listener {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
@@ -484,10 +483,9 @@ func slowCopyNWithDelay(sl *shutdownListener, dst io.Writer, src io.Reader, n in
|
||||
}
|
||||
}
|
||||
|
||||
// StartTestLimitServer connections limit server
|
||||
func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
|
||||
// StartTestConLimitServer connections limit server
|
||||
func StartTestConLimitServer(maxConnections int32) net.Listener {
|
||||
var connections atomic.Int32
|
||||
var slowOnce atomic.Bool
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
@@ -500,19 +498,11 @@ func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
|
||||
writer.WriteHeader(403)
|
||||
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(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
if useInitialDelay {
|
||||
slowCopyAfterDelay(sl, writer, file, n, delay)
|
||||
return
|
||||
}
|
||||
slowCopyN(sl, writer, file, n, 0)
|
||||
},
|
||||
)
|
||||
@@ -711,10 +701,13 @@ func rangeFileHandle(writer http.ResponseWriter, request *http.Request, modifyEn
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
end, err := strconv.ParseInt(s[1], 10, 64)
|
||||
if err != nil {
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
end := int64(BuildSize - 1)
|
||||
if s[1] != "" {
|
||||
end, err = strconv.ParseInt(s[1], 10, 64)
|
||||
if err != nil {
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
}
|
||||
if start < 0 || end < 0 || start > end {
|
||||
writer.WriteHeader(400)
|
||||
@@ -880,7 +873,7 @@ func StartSocks5Server(usr, pwd string) net.Listener {
|
||||
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 {
|
||||
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
|
||||
type Request struct {
|
||||
URL string `json:"url"`
|
||||
Extra any `json:"extra"`
|
||||
RawURL string `json:"rawUrl"`
|
||||
URL string `json:"url"`
|
||||
Extra any `json:"extra"`
|
||||
// Labels is used to mark the download task
|
||||
Labels map[string]string `json:"labels"`
|
||||
// Proxy is special proxy config for request
|
||||
@@ -24,6 +25,24 @@ type Request struct {
|
||||
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 {
|
||||
if r.URL == "" {
|
||||
return fmt.Errorf("invalid request url")
|
||||
|
||||
+513
-146
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 (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
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/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/xhr"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja_nodejs/eventloop"
|
||||
gojaurl "github.com/dop251/goja_nodejs/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed polyfill/out/index.js
|
||||
@@ -24,93 +27,118 @@ type Engine struct {
|
||||
Runtime *goja.Runtime
|
||||
}
|
||||
|
||||
type JSFunction func(goja.FunctionCall) goja.Value
|
||||
|
||||
// RunString executes the script and returns the go type value
|
||||
// if script result is promise, it will be resolved
|
||||
func (e *Engine) RunString(script string) (value any, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
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)
|
||||
}
|
||||
return e.runOnLoop(func(runtime *goja.Runtime) (goja.Value, error) {
|
||||
return runtime.RunString(script)
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return resolveResult(result)
|
||||
}
|
||||
|
||||
// CallFunction calls the function and returns the go type value
|
||||
// if function result is promise, it will be resolved
|
||||
func (e *Engine) CallFunction(fn goja.Callable, args ...any) (value any, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = r.(error)
|
||||
func (e *Engine) CallFunction(fn any, args ...any) (value any, err error) {
|
||||
return e.runOnLoop(func(runtime *goja.Runtime) (goja.Value, error) {
|
||||
var jsArgs []goja.Value
|
||||
for _, arg := range args {
|
||||
jsArgs = append(jsArgs, runtime.ToValue(arg))
|
||||
}
|
||||
}()
|
||||
|
||||
var result goja.Value
|
||||
e.loop.Run(func(runtime *goja.Runtime) {
|
||||
if args == nil {
|
||||
result, err = fn(nil)
|
||||
} else {
|
||||
var jsArgs []goja.Value
|
||||
for _, arg := range args {
|
||||
jsArgs = append(jsArgs, runtime.ToValue(arg))
|
||||
switch f := fn.(type) {
|
||||
case goja.Callable:
|
||||
if args == nil {
|
||||
return f(nil)
|
||||
}
|
||||
result, err = fn(nil, jsArgs...)
|
||||
}
|
||||
if err == nil {
|
||||
go e.await(result)
|
||||
return f(nil, jsArgs...)
|
||||
case JSFunction:
|
||||
return callExportedFunction(func(call goja.FunctionCall) goja.Value {
|
||||
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.
|
||||
// This method will stop the event loop when the promise result is resolved.
|
||||
func (e *Engine) await(value any) {
|
||||
if value == nil {
|
||||
return
|
||||
func (e *Engine) runOnLoop(fn func(runtime *goja.Runtime) (goja.Value, error)) (any, error) {
|
||||
type result struct {
|
||||
value any
|
||||
err error
|
||||
}
|
||||
|
||||
if v, ok := value.(goja.Value); ok {
|
||||
// if result is promise, wait for it to be resolved
|
||||
if p, ok := v.Export().(*goja.Promise); ok {
|
||||
if p.State() != goja.PromiseStatePending {
|
||||
ch := make(chan result, 1)
|
||||
ok := e.loop.RunOnLoop(func(runtime *goja.Runtime) {
|
||||
var once sync.Once
|
||||
sendResult := func(res result) {
|
||||
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
|
||||
}
|
||||
|
||||
// check promise state every 100 milliseconds, until it is resolved
|
||||
for {
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
if p.State() == goja.PromiseStatePending {
|
||||
continue
|
||||
}
|
||||
break
|
||||
promiseObj := value.ToObject(runtime)
|
||||
thenVal := promiseObj.Get("then")
|
||||
thenFn, ok := goja.AssertFunction(thenVal)
|
||||
if !ok {
|
||||
sendResult(result{err: errors.New("promise.then is not callable")})
|
||||
return
|
||||
}
|
||||
|
||||
// stop the event loop
|
||||
e.loop.StopNoWait()
|
||||
onFulfilled := runtime.ToValue(func(call goja.FunctionCall) goja.Value {
|
||||
sendResult(result{value: exportJSValue(call.Argument(0))})
|
||||
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() {
|
||||
e.loop.StopNoWait()
|
||||
e.loop.Terminate()
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ProxyConfig *base.DownloaderProxyConfig
|
||||
ProxyConfig *base.DownloaderProxyConfig
|
||||
StreamConfig *stream.Config
|
||||
}
|
||||
|
||||
func NewEngine(cfg *Config) *Engine {
|
||||
@@ -121,7 +149,10 @@ func NewEngine(cfg *Config) *Engine {
|
||||
engine := &Engine{
|
||||
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
|
||||
runtime.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
|
||||
vm.Enable(runtime)
|
||||
@@ -153,8 +184,12 @@ func NewEngine(cfg *Config) *Engine {
|
||||
if _, err := runtime.RunString("global.location = new URL('http://localhost');"); err != nil {
|
||||
return
|
||||
}
|
||||
if err := stream.Enable(runtime, loop, cfg.StreamConfig); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
})
|
||||
<-done
|
||||
return engine
|
||||
}
|
||||
|
||||
@@ -163,32 +198,45 @@ func Run(script string) (value any, err error) {
|
||||
return engine.RunString(script)
|
||||
}
|
||||
|
||||
// if the value is Promise, it will be resolved and return the result.
|
||||
func resolveResult(value goja.Value) (any, error) {
|
||||
export := value.Export()
|
||||
switch export.(type) {
|
||||
case *goja.Promise:
|
||||
p := export.(*goja.Promise)
|
||||
switch p.State() {
|
||||
case goja.PromiseStatePending:
|
||||
return nil, nil
|
||||
case goja.PromiseStateFulfilled:
|
||||
return p.Result().Export(), nil
|
||||
case goja.PromiseStateRejected:
|
||||
if err, ok := p.Result().Export().(error); ok {
|
||||
return nil, err
|
||||
} else {
|
||||
stack := p.Result().String()
|
||||
result := p.Result()
|
||||
if ro, ok := result.(*goja.Object); ok {
|
||||
stackVal := ro.Get("stack")
|
||||
if stackVal != nil && stackVal.String() != "" {
|
||||
stack = stackVal.String()
|
||||
}
|
||||
}
|
||||
return nil, errors.New(stack)
|
||||
}
|
||||
func exportJSValue(value goja.Value) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return value.Export()
|
||||
}
|
||||
|
||||
func exportJSError(value goja.Value) error {
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return errors.New("promise rejected")
|
||||
}
|
||||
if err, ok := value.Export().(error); ok {
|
||||
return err
|
||||
}
|
||||
stack := value.String()
|
||||
if ro, ok := value.(*goja.Object); ok {
|
||||
stackVal := ro.Get("stack")
|
||||
if stackVal != nil && stackVal.String() != "" {
|
||||
stack = stackVal.String()
|
||||
}
|
||||
}
|
||||
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, "XMLHttpRequest")
|
||||
doTestPolyfill(t, "Blob")
|
||||
doTestPolyfill(t, "Event")
|
||||
doTestPolyfill(t, "EventTarget")
|
||||
doTestPolyfill(t, "FormData")
|
||||
doTestPolyfill(t, "AbortController")
|
||||
doTestPolyfill(t, "AbortSignal")
|
||||
doTestPolyfill(t, "TextDecoder")
|
||||
doTestPolyfill(t, "TextEncoder")
|
||||
doTestPolyfill(t, "fetch")
|
||||
@@ -36,13 +40,75 @@ func TestPolyfill(t *testing.T) {
|
||||
func TestError(t *testing.T) {
|
||||
engine := NewEngine(nil)
|
||||
_, err := engine.RunString(`
|
||||
throw new MessageError('test');
|
||||
throw new MessageError('test');
|
||||
`)
|
||||
if me, ok := gojautil.AssertError[*gojaerror.MessageError](err); !ok {
|
||||
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) {
|
||||
server := startServer()
|
||||
defer server.Close()
|
||||
@@ -64,6 +130,50 @@ async function testText(){
|
||||
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){
|
||||
const resp = await fetch(host+'/octetStream',{
|
||||
method: 'POST',
|
||||
@@ -251,6 +361,32 @@ async function testFingerprintSafari(){
|
||||
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() {
|
||||
jsFile, _, md5 := buildFile(t, engine.Runtime)
|
||||
result, err = callTestFun(engine, "testOctetStream", jsFile)
|
||||
@@ -464,6 +600,18 @@ func startServer() net.Listener {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
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) {
|
||||
md5 := calcMd5(r.Body)
|
||||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"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) {
|
||||
defer func() {
|
||||
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
|
||||
+893
-2128
File diff suppressed because it is too large.
Load diff
@@ -18,7 +18,9 @@
|
||||
"webpack-cli": "^5.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"abortcontroller-polyfill": "^1.7.8",
|
||||
"blob-polyfill": "^7.0.20220408",
|
||||
"event-target-shim": "^6.0.2",
|
||||
"fastestsmallesttextencoderdecoder": "^1.0.22",
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import "../event/index.js";
|
||||
import "abortcontroller-polyfill/dist/abortcontroller-polyfill-only.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Event, EventTarget } from "event-target-shim";
|
||||
|
||||
if (typeof globalThis.Event !== "function") {
|
||||
globalThis.Event = Event;
|
||||
}
|
||||
|
||||
if (typeof globalThis.EventTarget !== "function") {
|
||||
globalThis.EventTarget = EventTarget;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import "./event/index.js"
|
||||
import "./abort/index.js"
|
||||
import "./blob/index.js"
|
||||
import "./crypto/index.js"
|
||||
// polyfill TextEncoder
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package webview
|
||||
|
||||
type Provider interface {
|
||||
IsAvailable() bool
|
||||
Open(opts OpenOptions) (Page, error)
|
||||
}
|
||||
|
||||
type unavailableProvider struct{}
|
||||
|
||||
func NewUnavailableProvider() Provider {
|
||||
return unavailableProvider{}
|
||||
}
|
||||
|
||||
func (unavailableProvider) IsAvailable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (unavailableProvider) Open(OpenOptions) (Page, error) {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
Loaded 50 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user