mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-13 03:43:41 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c2321d686 | ||
|
|
78e23217f9 | ||
|
|
61f62ec39a | ||
|
|
5d76d062ad | ||
|
|
fc489b6b97 |
No files matched your search
+11
-13
@@ -13,8 +13,8 @@ on:
|
|||||||
default: "false"
|
default: "false"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.25"
|
GO_VERSION: "1.24"
|
||||||
FLUTTER_VERSION: "3.41.6"
|
FLUTTER_VERSION: "3.41.2"
|
||||||
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||||
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
|
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ jobs:
|
|||||||
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
||||||
$b >> $env:GITHUB_PATH
|
$b >> $env:GITHUB_PATH
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
@@ -198,7 +198,7 @@ jobs:
|
|||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- name: Build
|
- name: Build
|
||||||
@@ -224,7 +224,7 @@ jobs:
|
|||||||
needs: [get-release, build-macos-amd64-lib]
|
needs: [get-release, build-macos-amd64-lib]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
@@ -315,7 +315,7 @@ jobs:
|
|||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
@@ -324,7 +324,7 @@ jobs:
|
|||||||
channel: master
|
channel: master
|
||||||
- run: |
|
- run: |
|
||||||
sudo apt update -y
|
sudo apt update -y
|
||||||
sudo apt install -y ninja-build libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate libkeybinder-3.0-dev
|
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate libkeybinder-3.0-dev
|
||||||
arch=$(uname -m)
|
arch=$(uname -m)
|
||||||
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
|
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
|
||||||
chmod +x appimagetool
|
chmod +x appimagetool
|
||||||
@@ -411,7 +411,7 @@ jobs:
|
|||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- name: Setup LXD
|
- name: Setup LXD
|
||||||
@@ -480,7 +480,6 @@ jobs:
|
|||||||
- cmake
|
- cmake
|
||||||
- curl
|
- curl
|
||||||
- libgtk-3-dev
|
- libgtk-3-dev
|
||||||
- libwebkit2gtk-4.1-dev
|
|
||||||
- libkeybinder-3.0-dev
|
- libkeybinder-3.0-dev
|
||||||
- ninja-build
|
- ninja-build
|
||||||
- unzip
|
- unzip
|
||||||
@@ -496,7 +495,6 @@ jobs:
|
|||||||
source: .
|
source: .
|
||||||
plugin: nil
|
plugin: nil
|
||||||
stage-packages:
|
stage-packages:
|
||||||
- libwebkit2gtk-4.1-0
|
|
||||||
- libgtk-3-dev
|
- libgtk-3-dev
|
||||||
- libkeybinder-3.0-dev
|
- libkeybinder-3.0-dev
|
||||||
- libappindicator3-dev
|
- libappindicator3-dev
|
||||||
@@ -547,7 +545,7 @@ jobs:
|
|||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: actions/setup-java@v3
|
- uses: actions/setup-java@v3
|
||||||
@@ -589,7 +587,7 @@ jobs:
|
|||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
@@ -622,7 +620,7 @@ jobs:
|
|||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ on:
|
|||||||
- "Dockerfile"
|
- "Dockerfile"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.25"
|
GO_VERSION: "1.24"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: release-drafter/release-drafter@v5
|
- uses: release-drafter/release-drafter@v5
|
||||||
|
|||||||
+10
-66
@@ -28,8 +28,8 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.25"
|
GO_VERSION: "1.24"
|
||||||
FLUTTER_VERSION: "3.41.6"
|
FLUTTER_VERSION: "3.41.2"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# lint:
|
# lint:
|
||||||
@@ -50,18 +50,14 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- name: Install webview dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -y
|
|
||||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev xvfb
|
|
||||||
- name: Run Unit tests.
|
- name: Run Unit tests.
|
||||||
run: |
|
run: |
|
||||||
# Get packages for testing and coverage (including cmd/web for flags_test.go)
|
# Get packages for testing and coverage (including cmd/web for flags_test.go)
|
||||||
PACKAGES=$(go list ./... | awk '!/\/bind\// && (!/\/cmd\// || /\/cmd\/web$/)')
|
PACKAGES=$(go list ./... | grep -v /bind/ | grep -v -E '/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
|
go test -v -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $PACKAGES
|
||||||
# Filter out main.go from coverage report to avoid lowering coverage percentage
|
# Filter out main.go from coverage report to avoid lowering coverage percentage
|
||||||
grep -v "main.go" coverage.txt > coverage_filtered.txt || true
|
grep -v "main.go" coverage.txt > coverage_filtered.txt || true
|
||||||
mv coverage_filtered.txt coverage.txt
|
mv coverage_filtered.txt coverage.txt
|
||||||
@@ -69,58 +65,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
files: ./coverage.txt
|
files: ./coverage.txt
|
||||||
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
||||||
test-webview-integration:
|
|
||||||
name: Test webview integration (${{ matrix.name }})
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: Windows
|
|
||||||
os: windows-latest
|
|
||||||
- name: macOS
|
|
||||||
os: macos-latest
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: ${{ env.GO_VERSION }}
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
if: runner.os == 'macOS'
|
|
||||||
with:
|
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
|
||||||
- name: Test webview
|
|
||||||
if: runner.os == 'Windows'
|
|
||||||
run: go test -tags webview -v -timeout 300s ./internal/webview/goprovider/
|
|
||||||
env:
|
|
||||||
CGO_ENABLED: 1
|
|
||||||
- name: Test webview
|
|
||||||
if: runner.os == 'macOS'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
SOCKET_PATH="$HOME/Library/Application Support/com.gopeed.gopeed/gopeed_webview.sock"
|
|
||||||
LOG_PATH="$RUNNER_TEMP/gopeed-webview-rpc.log"
|
|
||||||
rm -f "$SOCKET_PATH"
|
|
||||||
|
|
||||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
|
|
||||||
|
|
||||||
cd ui/flutter
|
|
||||||
flutter pub get
|
|
||||||
flutter run -d macos >"$LOG_PATH" 2>&1 &
|
|
||||||
FLUTTER_PID=$!
|
|
||||||
cd ../..
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
kill "$FLUTTER_PID" >/dev/null 2>&1 || true
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
if ! go test -tags webview -v -timeout 600s ./internal/webview/rpcprovider/ -args -webview-rpc-network unix -webview-rpc-address "$SOCKET_PATH"; then
|
|
||||||
cat "$LOG_PATH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
build-desktop:
|
build-desktop:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -165,7 +109,7 @@ jobs:
|
|||||||
"CGO_ENABLED=1" >> $env:GITHUB_ENV
|
"CGO_ENABLED=1" >> $env:GITHUB_ENV
|
||||||
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
||||||
$b >> $env:GITHUB_PATH
|
$b >> $env:GITHUB_PATH
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
@@ -185,7 +129,7 @@ jobs:
|
|||||||
- if: runner.os == 'Linux'
|
- if: runner.os == 'Linux'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update -y
|
sudo apt-get update -y
|
||||||
sudo apt-get install -y ninja-build libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev libkeybinder-3.0-dev
|
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
|
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
|
||||||
cd ui/flutter
|
cd ui/flutter
|
||||||
flutter build linux
|
flutter build linux
|
||||||
@@ -198,7 +142,7 @@ jobs:
|
|||||||
needs: [test]
|
needs: [test]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
@@ -230,7 +174,7 @@ jobs:
|
|||||||
needs: [test]
|
needs: [test]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
@@ -251,7 +195,7 @@ jobs:
|
|||||||
needs: [test]
|
needs: [test]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.25.4-alpine3.22 AS go
|
FROM golang:1.24.11-alpine3.23 AS go
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY ./go.mod ./go.sum ./
|
COPY ./go.mod ./go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ This project is divided into two parts, the front end uses `flutter`, the back e
|
|||||||
|
|
||||||
### 🌍 Environment
|
### 🌍 Environment
|
||||||
|
|
||||||
1. Golang 1.25+
|
1. Golang 1.24+
|
||||||
2. Flutter 3.38+
|
2. Flutter 3.38+
|
||||||
|
|
||||||
### 📋 Clone
|
### 📋 Clone
|
||||||
|
|||||||
+1
-1
@@ -182,7 +182,7 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
|||||||
|
|
||||||
### 🌍 環境
|
### 🌍 環境
|
||||||
|
|
||||||
1. Go 言語 1.25+
|
1. Go 言語 1.24+
|
||||||
2. Flutter 3.38+
|
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
|
### 🌍 Environment
|
||||||
|
|
||||||
1. Golang 1.25+
|
1. Golang 1.24+
|
||||||
2. Flutter 3.38+
|
2. Flutter 3.38+
|
||||||
|
|
||||||
### 📋 Clone
|
### 📋 Clone
|
||||||
|
|||||||
+1
-1
@@ -192,7 +192,7 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、
|
|||||||
|
|
||||||
### 🌍 环境要求
|
### 🌍 环境要求
|
||||||
|
|
||||||
1. Golang 1.25+
|
1. Golang 1.24+
|
||||||
2. Flutter 3.38+
|
2. Flutter 3.38+
|
||||||
|
|
||||||
### 📋 克隆项目
|
### 📋 克隆项目
|
||||||
|
|||||||
+1
-1
@@ -189,7 +189,7 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
|||||||
|
|
||||||
### 🌍 開發環境
|
### 🌍 開發環境
|
||||||
|
|
||||||
1. Golang 1.25+
|
1. Golang 1.24+
|
||||||
2. Flutter 3.38+
|
2. Flutter 3.38+
|
||||||
|
|
||||||
### 📋 克隆項目
|
### 📋 克隆項目
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ func Start(cfg *C.char) (int, *C.char) {
|
|||||||
return 0, C.CString(err.Error())
|
return 0, C.CString(err.Error())
|
||||||
}
|
}
|
||||||
config.ProductionMode = true
|
config.ProductionMode = true
|
||||||
applyWebViewProvider(&config)
|
|
||||||
realPort, err := rest.Start(&config)
|
realPort, err := rest.Start(&config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, C.CString(err.Error())
|
return 0, C.CString(err.Error())
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
//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()
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
//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,7 +4,6 @@ package libgopeed
|
|||||||
import "C"
|
import "C"
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest"
|
"github.com/GopeedLab/gopeed/pkg/rest"
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
)
|
)
|
||||||
@@ -15,17 +14,9 @@ func Start(cfg string) (int, error) {
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
config.ProductionMode = true
|
config.ProductionMode = true
|
||||||
applyWebViewProvider(&config)
|
|
||||||
return rest.Start(&config)
|
return rest.Start(&config)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Stop() {
|
func Stop() {
|
||||||
rest.Stop()
|
rest.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyWebViewProvider(config *model.StartConfig) {
|
|
||||||
if config == nil || config.WebViewRPCConfig == nil || !config.WebViewRPCConfig.Enabled() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
config.WebViewProvider = rpcprovider.New(*config.WebViewRPCConfig)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,10 @@
|
|||||||
module github.com/GopeedLab/gopeed
|
module github.com/GopeedLab/gopeed
|
||||||
|
|
||||||
go 1.25.4
|
go 1.24.9
|
||||||
|
|
||||||
|
toolchain go1.24.11
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5
|
|
||||||
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
|
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||||
github.com/bodgit/sevenzip v1.6.1
|
github.com/bodgit/sevenzip v1.6.1
|
||||||
@@ -16,7 +17,6 @@ require (
|
|||||||
github.com/matoous/go-nanoid/v2 v2.0.0
|
github.com/matoous/go-nanoid/v2 v2.0.0
|
||||||
github.com/mattn/go-ieproxy v0.0.12
|
github.com/mattn/go-ieproxy v0.0.12
|
||||||
github.com/mholt/archives v0.1.5
|
github.com/mholt/archives v0.1.5
|
||||||
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447
|
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
|
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
|
||||||
github.com/rs/zerolog v1.31.0
|
github.com/rs/zerolog v1.31.0
|
||||||
@@ -45,6 +45,7 @@ require (
|
|||||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||||
github.com/minio/minlz 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/nwaples/rardecode/v2 v2.2.0 // indirect
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
|
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmG
|
|||||||
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
|
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5 h1:86LVTaNDM/j/uTAD2HedkXNO9LIQlt642VAdbU/0D/M=
|
|
||||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
|
|
||||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
@@ -388,6 +386,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/monkeyWie/goed2k v0.0.0-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 h1:88kRsNwkDKPA4NzRUWdOoIL7SyMNDwtmAxzShKBvKTI=
|
||||||
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447/go.mod h1:Ry2y1QlzerUgA1hVmExBdXXzE4Sjk1M7w0nSh6dhDOg=
|
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447/go.mod h1:Ry2y1QlzerUgA1hVmExBdXXzE4Sjk1M7w0nSh6dhDOg=
|
||||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||||
|
|||||||
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) {
|
func TestFetcher_DownloadLimit(t *testing.T) {
|
||||||
listener := test.StartTestConLimitServer(4)
|
listener := test.StartTestLimitServer(4, 0)
|
||||||
defer listener.Close()
|
defer listener.Close()
|
||||||
|
|
||||||
downloadNormal(listener, 1, t)
|
downloadNormal(listener, 1, t)
|
||||||
@@ -614,7 +614,7 @@ func TestFetcher_SlowStartExpansion(t *testing.T) {
|
|||||||
os.Remove(test.DownloadFile)
|
os.Remove(test.DownloadFile)
|
||||||
|
|
||||||
// Use 100ns delay per byte for faster test (~10MB/s theoretical)
|
// Use 100ns delay per byte for faster test (~10MB/s theoretical)
|
||||||
listener := test.StartTestLowSpeedServer(100 * time.Nanosecond)
|
listener := test.StartTestSlowStartServer(100 * time.Nanosecond)
|
||||||
|
|
||||||
// Ensure cleanup happens before next subtest
|
// Ensure cleanup happens before next subtest
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
@@ -758,7 +758,7 @@ func TestFetcher_AsyncPrefetch(t *testing.T) {
|
|||||||
t.Run("PrefetchPartial", func(t *testing.T) {
|
t.Run("PrefetchPartial", func(t *testing.T) {
|
||||||
// Use slow server with 100 nanosecond delay per byte
|
// Use slow server with 100 nanosecond delay per byte
|
||||||
// This means ~10MB/s speed, so 100ms should download ~1MB
|
// This means ~10MB/s speed, so 100ms should download ~1MB
|
||||||
listener := test.StartTestLowSpeedServer(100 * time.Nanosecond)
|
listener := test.StartTestSlowStartServer(100 * time.Nanosecond)
|
||||||
defer listener.Close()
|
defer listener.Close()
|
||||||
|
|
||||||
fetcher := buildFetcher()
|
fetcher := buildFetcher()
|
||||||
|
|||||||
@@ -433,8 +433,9 @@ func StartTestExpiringRedirectServer(requestsBeforeExpire int32, delayPerByte ti
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartTestLowSpeedServer creates a server with configurable delay per request
|
// StartTestSlowStartServer creates a server with configurable delay per request
|
||||||
func StartTestLowSpeedServer(delayPerByte time.Duration) net.Listener {
|
// This allows testing slow-start connection expansion to reach max connections
|
||||||
|
func StartTestSlowStartServer(delayPerByte time.Duration) net.Listener {
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||||
@@ -483,9 +484,10 @@ func slowCopyNWithDelay(sl *shutdownListener, dst io.Writer, src io.Reader, n in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartTestConLimitServer connections limit server
|
// StartTestLimitServer connections limit server
|
||||||
func StartTestConLimitServer(maxConnections int32) net.Listener {
|
func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
|
||||||
var connections atomic.Int32
|
var connections atomic.Int32
|
||||||
|
var slowOnce atomic.Bool
|
||||||
|
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -498,11 +500,19 @@ func StartTestConLimitServer(maxConnections int32) net.Listener {
|
|||||||
writer.WriteHeader(403)
|
writer.WriteHeader(403)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// First request intentionally delays the first write to trigger a single read timeout,
|
||||||
|
// subsequent requests respond at normal speed.
|
||||||
|
useInitialDelay := delay > 0 && !slowOnce.Swap(true)
|
||||||
rangeFileHandle(
|
rangeFileHandle(
|
||||||
writer,
|
writer,
|
||||||
request,
|
request,
|
||||||
nil,
|
nil,
|
||||||
func(file *os.File, n int64) {
|
func(file *os.File, n int64) {
|
||||||
|
if useInitialDelay {
|
||||||
|
slowCopyAfterDelay(sl, writer, file, n, delay)
|
||||||
|
return
|
||||||
|
}
|
||||||
slowCopyN(sl, writer, file, n, 0)
|
slowCopyN(sl, writer, file, n, 0)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -701,14 +711,11 @@ func rangeFileHandle(writer http.ResponseWriter, request *http.Request, modifyEn
|
|||||||
writer.WriteHeader(400)
|
writer.WriteHeader(400)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
end := int64(BuildSize - 1)
|
end, err := strconv.ParseInt(s[1], 10, 64)
|
||||||
if s[1] != "" {
|
|
||||||
end, err = strconv.ParseInt(s[1], 10, 64)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writer.WriteHeader(400)
|
writer.WriteHeader(400)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if start < 0 || end < 0 || start > end {
|
if start < 0 || end < 0 || start > end {
|
||||||
writer.WriteHeader(400)
|
writer.WriteHeader(400)
|
||||||
return
|
return
|
||||||
@@ -873,7 +880,7 @@ func StartSocks5Server(usr, pwd string) net.Listener {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
listener, err := net.Listen("tcp", "127.0.0.1:0") // 你可以根据需要更改监听地址
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
//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
@@ -1,15 +0,0 @@
|
|||||||
//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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
//go:build !cgo
|
|
||||||
|
|
||||||
package goprovider
|
|
||||||
|
|
||||||
import enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
|
||||||
|
|
||||||
func New() enginewebview.Provider {
|
|
||||||
return enginewebview.NewUnavailableProvider()
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
//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) {}
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
//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")
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
|
|
||||||
// Request download request
|
// Request download request
|
||||||
type Request struct {
|
type Request struct {
|
||||||
RawURL string `json:"rawUrl"`
|
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Extra any `json:"extra"`
|
Extra any `json:"extra"`
|
||||||
// Labels is used to mark the download task
|
// Labels is used to mark the download task
|
||||||
@@ -25,24 +24,6 @@ type Request struct {
|
|||||||
SkipVerifyCert bool `json:"skipVerifyCert"`
|
SkipVerifyCert bool `json:"skipVerifyCert"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLabels replaces all labels on the request.
|
|
||||||
func (r *Request) SetLabels(labels map[string]string) {
|
|
||||||
r.Labels = labels
|
|
||||||
}
|
|
||||||
|
|
||||||
// PutLabel sets a label on the request.
|
|
||||||
func (r *Request) PutLabel(key, value string) {
|
|
||||||
if r.Labels == nil {
|
|
||||||
r.Labels = make(map[string]string)
|
|
||||||
}
|
|
||||||
r.Labels[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
// DelLabel deletes a label from the request.
|
|
||||||
func (r *Request) DelLabel(key string) {
|
|
||||||
delete(r.Labels, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Request) Validate() error {
|
func (r *Request) Validate() error {
|
||||||
if r.URL == "" {
|
if r.URL == "" {
|
||||||
return fmt.Errorf("invalid request url")
|
return fmt.Errorf("invalid request url")
|
||||||
|
|||||||
+39
-406
File diff suppressed because it is too large.
Load diff
+33
-687
File diff suppressed because it is too large.
Load diff
+78
-126
@@ -3,19 +3,16 @@ package engine
|
|||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
||||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
|
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
|
||||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
|
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
|
||||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/stream"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/vm"
|
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/vm"
|
||||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/xhr"
|
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/xhr"
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
"github.com/dop251/goja_nodejs/eventloop"
|
"github.com/dop251/goja_nodejs/eventloop"
|
||||||
gojaurl "github.com/dop251/goja_nodejs/url"
|
gojaurl "github.com/dop251/goja_nodejs/url"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed polyfill/out/index.js
|
//go:embed polyfill/out/index.js
|
||||||
@@ -27,118 +24,93 @@ type Engine struct {
|
|||||||
Runtime *goja.Runtime
|
Runtime *goja.Runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
type JSFunction func(goja.FunctionCall) goja.Value
|
|
||||||
|
|
||||||
// RunString executes the script and returns the go type value
|
// RunString executes the script and returns the go type value
|
||||||
// if script result is promise, it will be resolved
|
// if script result is promise, it will be resolved
|
||||||
func (e *Engine) RunString(script string) (value any, err error) {
|
func (e *Engine) RunString(script string) (value any, err error) {
|
||||||
return e.runOnLoop(func(runtime *goja.Runtime) (goja.Value, error) {
|
defer func() {
|
||||||
return runtime.RunString(script)
|
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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return resolveResult(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CallFunction calls the function and returns the go type value
|
// CallFunction calls the function and returns the go type value
|
||||||
// if function result is promise, it will be resolved
|
// if function result is promise, it will be resolved
|
||||||
func (e *Engine) CallFunction(fn any, args ...any) (value any, err error) {
|
func (e *Engine) CallFunction(fn goja.Callable, args ...any) (value any, err error) {
|
||||||
return e.runOnLoop(func(runtime *goja.Runtime) (goja.Value, error) {
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
err = r.(error)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var result goja.Value
|
||||||
|
e.loop.Run(func(runtime *goja.Runtime) {
|
||||||
|
if args == nil {
|
||||||
|
result, err = fn(nil)
|
||||||
|
} else {
|
||||||
var jsArgs []goja.Value
|
var jsArgs []goja.Value
|
||||||
for _, arg := range args {
|
for _, arg := range args {
|
||||||
jsArgs = append(jsArgs, runtime.ToValue(arg))
|
jsArgs = append(jsArgs, runtime.ToValue(arg))
|
||||||
}
|
}
|
||||||
switch f := fn.(type) {
|
result, err = fn(nil, jsArgs...)
|
||||||
case goja.Callable:
|
|
||||||
if args == nil {
|
|
||||||
return f(nil)
|
|
||||||
}
|
}
|
||||||
return f(nil, jsArgs...)
|
if err == nil {
|
||||||
case JSFunction:
|
go e.await(result)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) runOnLoop(fn func(runtime *goja.Runtime) (goja.Value, error)) (any, error) {
|
// loop.Run will hang if the script result has a non-stop code, such as setInterval.
|
||||||
type result struct {
|
// This method will stop the event loop when the promise result is resolved.
|
||||||
value any
|
func (e *Engine) await(value any) {
|
||||||
err error
|
if value == nil {
|
||||||
}
|
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
if p, ok := value.Export().(*goja.Promise); ok {
|
|
||||||
switch p.State() {
|
if v, ok := value.(goja.Value); ok {
|
||||||
case goja.PromiseStateFulfilled:
|
// if result is promise, wait for it to be resolved
|
||||||
sendResult(result{value: exportJSValue(p.Result())})
|
if p, ok := v.Export().(*goja.Promise); ok {
|
||||||
return
|
if p.State() != goja.PromiseStatePending {
|
||||||
case goja.PromiseStateRejected:
|
|
||||||
sendResult(result{err: exportJSError(p.Result())})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
promiseObj := value.ToObject(runtime)
|
|
||||||
thenVal := promiseObj.Get("then")
|
// check promise state every 100 milliseconds, until it is resolved
|
||||||
thenFn, ok := goja.AssertFunction(thenVal)
|
for {
|
||||||
if !ok {
|
time.Sleep(time.Millisecond * 100)
|
||||||
sendResult(result{err: errors.New("promise.then is not callable")})
|
if p.State() == goja.PromiseStatePending {
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
onFulfilled := runtime.ToValue(func(call goja.FunctionCall) goja.Value {
|
break
|
||||||
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
|
|
||||||
|
// stop the event loop
|
||||||
|
e.loop.StopNoWait()
|
||||||
}
|
}
|
||||||
sendResult(result{value: exportJSValue(value)})
|
|
||||||
})
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("engine loop terminated")
|
|
||||||
}
|
}
|
||||||
res := <-ch
|
|
||||||
if res.err != nil {
|
|
||||||
return nil, res.err
|
|
||||||
}
|
|
||||||
return res.value, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) Close() {
|
func (e *Engine) Close() {
|
||||||
e.loop.Terminate()
|
e.loop.StopNoWait()
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ProxyConfig *base.DownloaderProxyConfig
|
ProxyConfig *base.DownloaderProxyConfig
|
||||||
StreamConfig *stream.Config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEngine(cfg *Config) *Engine {
|
func NewEngine(cfg *Config) *Engine {
|
||||||
@@ -149,10 +121,7 @@ func NewEngine(cfg *Config) *Engine {
|
|||||||
engine := &Engine{
|
engine := &Engine{
|
||||||
loop: loop,
|
loop: loop,
|
||||||
}
|
}
|
||||||
loop.Start()
|
loop.Run(func(runtime *goja.Runtime) {
|
||||||
done := make(chan struct{})
|
|
||||||
loop.RunOnLoop(func(runtime *goja.Runtime) {
|
|
||||||
defer close(done)
|
|
||||||
engine.Runtime = runtime
|
engine.Runtime = runtime
|
||||||
runtime.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
|
runtime.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
|
||||||
vm.Enable(runtime)
|
vm.Enable(runtime)
|
||||||
@@ -184,12 +153,8 @@ func NewEngine(cfg *Config) *Engine {
|
|||||||
if _, err := runtime.RunString("global.location = new URL('http://localhost');"); err != nil {
|
if _, err := runtime.RunString("global.location = new URL('http://localhost');"); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := stream.Enable(runtime, loop, cfg.StreamConfig); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
})
|
})
|
||||||
<-done
|
|
||||||
return engine
|
return engine
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,45 +163,32 @@ func Run(script string) (value any, err error) {
|
|||||||
return engine.RunString(script)
|
return engine.RunString(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
func exportJSValue(value goja.Value) any {
|
// if the value is Promise, it will be resolved and return the result.
|
||||||
if value == nil {
|
func resolveResult(value goja.Value) (any, error) {
|
||||||
return nil
|
export := value.Export()
|
||||||
}
|
switch export.(type) {
|
||||||
return value.Export()
|
case *goja.Promise:
|
||||||
}
|
p := export.(*goja.Promise)
|
||||||
|
switch p.State() {
|
||||||
func exportJSError(value goja.Value) error {
|
case goja.PromiseStatePending:
|
||||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
return nil, nil
|
||||||
return errors.New("promise rejected")
|
case goja.PromiseStateFulfilled:
|
||||||
}
|
return p.Result().Export(), nil
|
||||||
if err, ok := value.Export().(error); ok {
|
case goja.PromiseStateRejected:
|
||||||
return err
|
if err, ok := p.Result().Export().(error); ok {
|
||||||
}
|
return nil, err
|
||||||
stack := value.String()
|
} else {
|
||||||
if ro, ok := value.(*goja.Object); ok {
|
stack := p.Result().String()
|
||||||
|
result := p.Result()
|
||||||
|
if ro, ok := result.(*goja.Object); ok {
|
||||||
stackVal := ro.Get("stack")
|
stackVal := ro.Get("stack")
|
||||||
if stackVal != nil && stackVal.String() != "" {
|
if stackVal != nil && stackVal.String() != "" {
|
||||||
stack = stackVal.String()
|
stack = stackVal.String()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return errors.New(stack)
|
return nil, 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{
|
return export, nil
|
||||||
This: nil,
|
|
||||||
Arguments: args,
|
|
||||||
}), nil
|
|
||||||
}
|
}
|
||||||
@@ -26,11 +26,7 @@ func TestPolyfill(t *testing.T) {
|
|||||||
doTestPolyfill(t, "MessageError")
|
doTestPolyfill(t, "MessageError")
|
||||||
doTestPolyfill(t, "XMLHttpRequest")
|
doTestPolyfill(t, "XMLHttpRequest")
|
||||||
doTestPolyfill(t, "Blob")
|
doTestPolyfill(t, "Blob")
|
||||||
doTestPolyfill(t, "Event")
|
|
||||||
doTestPolyfill(t, "EventTarget")
|
|
||||||
doTestPolyfill(t, "FormData")
|
doTestPolyfill(t, "FormData")
|
||||||
doTestPolyfill(t, "AbortController")
|
|
||||||
doTestPolyfill(t, "AbortSignal")
|
|
||||||
doTestPolyfill(t, "TextDecoder")
|
doTestPolyfill(t, "TextDecoder")
|
||||||
doTestPolyfill(t, "TextEncoder")
|
doTestPolyfill(t, "TextEncoder")
|
||||||
doTestPolyfill(t, "fetch")
|
doTestPolyfill(t, "fetch")
|
||||||
@@ -47,68 +43,6 @@ func TestError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCallFunction_DetachedAsyncWorkDoesNotBlockReturn(t *testing.T) {
|
|
||||||
engine := NewEngine(nil)
|
|
||||||
defer engine.Close()
|
|
||||||
if _, err := engine.RunString(`
|
|
||||||
globalThis.__done = false;
|
|
||||||
async function testDetached() {
|
|
||||||
(async () => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
globalThis.__done = true;
|
|
||||||
})();
|
|
||||||
return "ok";
|
|
||||||
}
|
|
||||||
`); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
fn, ok := goja.AssertFunction(engine.Runtime.Get("testDetached"))
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("testDetached is not callable")
|
|
||||||
}
|
|
||||||
startedAt := time.Now()
|
|
||||||
value, err := engine.CallFunction(fn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
elapsed := time.Since(startedAt)
|
|
||||||
if elapsed > 200*time.Millisecond {
|
|
||||||
t.Fatalf("detached async work blocked return for %s", elapsed)
|
|
||||||
}
|
|
||||||
if value != "ok" {
|
|
||||||
t.Fatalf("unexpected return value: %#v", value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCallFunction_AwaitedPromiseBlocksUntilSettled(t *testing.T) {
|
|
||||||
engine := NewEngine(nil)
|
|
||||||
defer engine.Close()
|
|
||||||
if _, err := engine.RunString(`
|
|
||||||
async function testAwaited() {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
||||||
return "ok";
|
|
||||||
}
|
|
||||||
`); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
fn, ok := goja.AssertFunction(engine.Runtime.Get("testAwaited"))
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("testAwaited is not callable")
|
|
||||||
}
|
|
||||||
startedAt := time.Now()
|
|
||||||
value, err := engine.CallFunction(fn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
elapsed := time.Since(startedAt)
|
|
||||||
if elapsed < 100*time.Millisecond {
|
|
||||||
t.Fatalf("awaited promise returned too early: %s", elapsed)
|
|
||||||
}
|
|
||||||
if value != "ok" {
|
|
||||||
t.Fatalf("unexpected return value: %#v", value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetch(t *testing.T) {
|
func TestFetch(t *testing.T) {
|
||||||
server := startServer()
|
server := startServer()
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
@@ -130,50 +64,6 @@ async function testText(){
|
|||||||
return await resp.text();
|
return await resp.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testResponseBodyGetReader() {
|
|
||||||
const resp = await fetch(host+'/text', {
|
|
||||||
method: 'POST',
|
|
||||||
body: 'stream-body'
|
|
||||||
});
|
|
||||||
if (!resp.body || typeof resp.body.getReader !== 'function') {
|
|
||||||
throw new Error('response.body.getReader is not available');
|
|
||||||
}
|
|
||||||
const reader = resp.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let result = '';
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
result += decoder.decode(value, { stream: true });
|
|
||||||
}
|
|
||||||
result += decoder.decode();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testStreamingFetchResponseBody() {
|
|
||||||
const startedAt = Date.now();
|
|
||||||
const resp = await fetch(host+'/stream');
|
|
||||||
const resolvedAt = Date.now() - startedAt;
|
|
||||||
const reader = resp.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
const first = await reader.read();
|
|
||||||
const firstAt = Date.now() - startedAt;
|
|
||||||
const second = await reader.read();
|
|
||||||
const secondAt = Date.now() - startedAt;
|
|
||||||
let result = '';
|
|
||||||
result += decoder.decode(first.value, { stream: true });
|
|
||||||
result += decoder.decode(second.value, { stream: true });
|
|
||||||
result += decoder.decode();
|
|
||||||
return {
|
|
||||||
resolvedAt,
|
|
||||||
firstAt,
|
|
||||||
secondAt,
|
|
||||||
text: result
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testOctetStream(file){
|
async function testOctetStream(file){
|
||||||
const resp = await fetch(host+'/octetStream',{
|
const resp = await fetch(host+'/octetStream',{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -361,32 +251,6 @@ async function testFingerprintSafari(){
|
|||||||
t.Fatalf("testText failed, want %s, got %s", "test", result)
|
t.Fatalf("testText failed, want %s, got %s", "test", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err = callTestFun(engine, "testResponseBodyGetReader")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if result != "stream-body" {
|
|
||||||
t.Fatalf("testResponseBodyGetReader failed, want %s, got %s", "stream-body", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err = callTestFun(engine, "testStreamingFetchResponseBody")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
streaming := result.(map[string]any)
|
|
||||||
if streaming["text"] != "chunk-1chunk-2" {
|
|
||||||
t.Fatalf("testStreamingFetchResponseBody failed, want %s, got %v", "chunk-1chunk-2", streaming["text"])
|
|
||||||
}
|
|
||||||
if int(streaming["resolvedAt"].(int64)) >= 120 {
|
|
||||||
t.Fatalf("fetch resolved too late, got %dms", streaming["resolvedAt"].(int64))
|
|
||||||
}
|
|
||||||
if int(streaming["firstAt"].(int64)) >= 120 {
|
|
||||||
t.Fatalf("first chunk arrived too late, got %dms", streaming["firstAt"].(int64))
|
|
||||||
}
|
|
||||||
if int(streaming["secondAt"].(int64)) < 120 {
|
|
||||||
t.Fatalf("second chunk arrived too early, got %dms", streaming["secondAt"].(int64))
|
|
||||||
}
|
|
||||||
|
|
||||||
func() {
|
func() {
|
||||||
jsFile, _, md5 := buildFile(t, engine.Runtime)
|
jsFile, _, md5 := buildFile(t, engine.Runtime)
|
||||||
result, err = callTestFun(engine, "testOctetStream", jsFile)
|
result, err = callTestFun(engine, "testOctetStream", jsFile)
|
||||||
@@ -600,18 +464,6 @@ func startServer() net.Listener {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write(buf)
|
w.Write(buf)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/stream", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
if flusher, ok := w.(http.Flusher); ok {
|
|
||||||
_, _ = w.Write([]byte("chunk-1"))
|
|
||||||
flusher.Flush()
|
|
||||||
time.Sleep(150 * time.Millisecond)
|
|
||||||
_, _ = w.Write([]byte("chunk-2"))
|
|
||||||
flusher.Flush()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = w.Write([]byte("chunk-1chunk-2"))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/octetStream", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/octetStream", func(w http.ResponseWriter, r *http.Request) {
|
||||||
md5 := calcMd5(r.Body)
|
md5 := calcMd5(r.Body)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,278 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
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,8 +1,6 @@
|
|||||||
package vm
|
package vm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
"github.com/dop251/goja_nodejs/eventloop"
|
"github.com/dop251/goja_nodejs/eventloop"
|
||||||
)
|
)
|
||||||
@@ -27,12 +25,7 @@ func (vm *Vm) Get(name string) (value any) {
|
|||||||
func (vm *Vm) RunString(script string) (value any, err error) {
|
func (vm *Vm) RunString(script string) (value any, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
switch v := r.(type) {
|
err = r.(error)
|
||||||
case error:
|
|
||||||
err = v
|
|
||||||
default:
|
|
||||||
err = fmt.Errorf("panic: %v", r)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
(()=>{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
|
(()=>{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
|
||||||
+2127
-892
File diff suppressed because it is too large.
Load diff
@@ -18,9 +18,7 @@
|
|||||||
"webpack-cli": "^5.0.1"
|
"webpack-cli": "^5.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"abortcontroller-polyfill": "^1.7.8",
|
|
||||||
"blob-polyfill": "^7.0.20220408",
|
"blob-polyfill": "^7.0.20220408",
|
||||||
"event-target-shim": "^6.0.2",
|
|
||||||
"fastestsmallesttextencoderdecoder": "^1.0.22",
|
"fastestsmallesttextencoderdecoder": "^1.0.22",
|
||||||
"whatwg-fetch": "^3.6.20"
|
"whatwg-fetch": "^3.6.20"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
import "../event/index.js";
|
|
||||||
import "abortcontroller-polyfill/dist/abortcontroller-polyfill-only.js";
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Event, EventTarget } from "event-target-shim";
|
|
||||||
|
|
||||||
if (typeof globalThis.Event !== "function") {
|
|
||||||
globalThis.Event = Event;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof globalThis.EventTarget !== "function") {
|
|
||||||
globalThis.EventTarget = EventTarget;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import "./event/index.js"
|
|
||||||
import "./abort/index.js"
|
|
||||||
import "./blob/index.js"
|
import "./blob/index.js"
|
||||||
import "./crypto/index.js"
|
import "./crypto/index.js"
|
||||||
// polyfill TextEncoder
|
// polyfill TextEncoder
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
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