mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 07:45:30 +00:00
feat: add WebView support for extension (#1359)
This commit is contained in:
+12
-10
@@ -13,7 +13,7 @@ on:
|
||||
default: "false"
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.24"
|
||||
GO_VERSION: "1.25"
|
||||
FLUTTER_VERSION: "3.41.6"
|
||||
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
||||
$b >> $env:GITHUB_PATH
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
@@ -198,7 +198,7 @@ jobs:
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- name: Build
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
needs: [get-release, build-macos-amd64-lib]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: actions/setup-node@v3
|
||||
@@ -315,7 +315,7 @@ jobs:
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
@@ -324,7 +324,7 @@ jobs:
|
||||
channel: master
|
||||
- run: |
|
||||
sudo apt update -y
|
||||
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate libkeybinder-3.0-dev
|
||||
sudo apt install -y ninja-build libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate libkeybinder-3.0-dev
|
||||
arch=$(uname -m)
|
||||
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
|
||||
chmod +x appimagetool
|
||||
@@ -411,7 +411,7 @@ jobs:
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- name: Setup LXD
|
||||
@@ -480,6 +480,7 @@ jobs:
|
||||
- cmake
|
||||
- curl
|
||||
- libgtk-3-dev
|
||||
- libwebkit2gtk-4.1-dev
|
||||
- libkeybinder-3.0-dev
|
||||
- ninja-build
|
||||
- unzip
|
||||
@@ -495,6 +496,7 @@ jobs:
|
||||
source: .
|
||||
plugin: nil
|
||||
stage-packages:
|
||||
- libwebkit2gtk-4.1-0
|
||||
- libgtk-3-dev
|
||||
- libkeybinder-3.0-dev
|
||||
- libappindicator3-dev
|
||||
@@ -545,7 +547,7 @@ jobs:
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: actions/setup-java@v3
|
||||
@@ -587,7 +589,7 @@ jobs:
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
@@ -620,7 +622,7 @@ jobs:
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
- "Dockerfile"
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.24"
|
||||
GO_VERSION: "1.25"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: release-drafter/release-drafter@v5
|
||||
|
||||
+70
-14
@@ -28,7 +28,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.24"
|
||||
GO_VERSION: "1.25"
|
||||
FLUTTER_VERSION: "3.41.6"
|
||||
|
||||
jobs:
|
||||
@@ -50,14 +50,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- name: Install webview dependencies
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev xvfb
|
||||
- name: Run Unit tests.
|
||||
run: |
|
||||
# Get packages for testing and coverage (including cmd/web for flags_test.go)
|
||||
PACKAGES=$(go list ./... | grep -v /bind/ | grep -v -E '/cmd/(?!web)')
|
||||
go test -v -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $PACKAGES
|
||||
PACKAGES=$(go list ./... | awk '!/\/bind\// && (!/\/cmd\// || /\/cmd\/web$/)')
|
||||
env CGO_ENABLED=1 xvfb-run -a go test -tags webview -v -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $PACKAGES
|
||||
# Filter out main.go from coverage report to avoid lowering coverage percentage
|
||||
grep -v "main.go" coverage.txt > coverage_filtered.txt || true
|
||||
mv coverage_filtered.txt coverage.txt
|
||||
@@ -65,6 +69,58 @@ jobs:
|
||||
with:
|
||||
files: ./coverage.txt
|
||||
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
||||
test-webview-integration:
|
||||
name: Test webview integration (${{ matrix.name }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Windows
|
||||
os: windows-latest
|
||||
- name: macOS
|
||||
os: macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
if: runner.os == 'macOS'
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- name: Test webview
|
||||
if: runner.os == 'Windows'
|
||||
run: go test -tags webview -v -timeout 300s ./internal/webview/goprovider/
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
- name: Test webview
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SOCKET_PATH="$HOME/Library/Application Support/com.gopeed.gopeed/gopeed_webview.sock"
|
||||
LOG_PATH="$RUNNER_TEMP/gopeed-webview-rpc.log"
|
||||
rm -f "$SOCKET_PATH"
|
||||
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
|
||||
|
||||
cd ui/flutter
|
||||
flutter pub get
|
||||
flutter run -d macos >"$LOG_PATH" 2>&1 &
|
||||
FLUTTER_PID=$!
|
||||
cd ../..
|
||||
|
||||
cleanup() {
|
||||
kill "$FLUTTER_PID" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if ! go test -tags webview -v -timeout 600s ./internal/webview/rpcprovider/ -args -webview-rpc-network unix -webview-rpc-address "$SOCKET_PATH"; then
|
||||
cat "$LOG_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-desktop:
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -80,7 +136,7 @@ jobs:
|
||||
flutter_channel: "main"
|
||||
name: Build desktop check (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: [test]
|
||||
needs: [ test ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Enable long paths for flutter main branch checks
|
||||
@@ -109,7 +165,7 @@ jobs:
|
||||
"CGO_ENABLED=1" >> $env:GITHUB_ENV
|
||||
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
||||
$b >> $env:GITHUB_PATH
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
@@ -129,7 +185,7 @@ jobs:
|
||||
- if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
|
||||
cd ui/flutter
|
||||
flutter build linux
|
||||
@@ -138,11 +194,11 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-14, ubuntu-latest]
|
||||
needs: [test]
|
||||
os: [ macos-14, ubuntu-latest ]
|
||||
needs: [ test ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
@@ -171,10 +227,10 @@ jobs:
|
||||
build-web:
|
||||
name: Build web check
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
needs: [ test ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
@@ -192,10 +248,10 @@ jobs:
|
||||
build-docker:
|
||||
name: Build docker check
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
needs: [ test ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.24.11-alpine3.23 AS go
|
||||
FROM golang:1.25.4-alpine3.22 AS go
|
||||
WORKDIR /app
|
||||
COPY ./go.mod ./go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
@@ -201,7 +201,7 @@ This project is divided into two parts, the front end uses `flutter`, the back e
|
||||
|
||||
### 🌍 Environment
|
||||
|
||||
1. Golang 1.24+
|
||||
1. Golang 1.25+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 📋 Clone
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
||||
|
||||
### 🌍 環境
|
||||
|
||||
1. Go 言語 1.24+
|
||||
1. Go 言語 1.25+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 📋 クローン
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ Dự án này được chia thành hai phần, phần giao diện sử dụng `f
|
||||
|
||||
### 🌍 Environment
|
||||
|
||||
1. Golang 1.24+
|
||||
1. Golang 1.25+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 📋 Clone
|
||||
|
||||
+1
-1
@@ -192,7 +192,7 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、
|
||||
|
||||
### 🌍 环境要求
|
||||
|
||||
1. Golang 1.24+
|
||||
1. Golang 1.25+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 📋 克隆项目
|
||||
|
||||
+1
-1
@@ -189,7 +189,7 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
|
||||
### 🌍 開發環境
|
||||
|
||||
1. Golang 1.24+
|
||||
1. Golang 1.25+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 📋 克隆項目
|
||||
|
||||
@@ -16,6 +16,7 @@ func Start(cfg *C.char) (int, *C.char) {
|
||||
return 0, C.CString(err.Error())
|
||||
}
|
||||
config.ProductionMode = true
|
||||
applyWebViewProvider(&config)
|
||||
realPort, err := rest.Start(&config)
|
||||
if err != nil {
|
||||
return 0, C.CString(err.Error())
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
|
||||
func applyWebViewProvider(config *model.StartConfig) {
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
config.WebViewProvider = newDesktopWebViewProvider(config.WebViewRPCConfig)
|
||||
}
|
||||
|
||||
func newDesktopWebViewProvider(rpcCfg *enginewebview.RPCConfig) enginewebview.Provider {
|
||||
if rpcCfg != nil && rpcCfg.Enabled() {
|
||||
return rpcprovider.New(*rpcCfg)
|
||||
}
|
||||
return enginewebview.NewUnavailableProvider()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build !darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/GopeedLab/gopeed/internal/webview/goprovider"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
|
||||
func applyWebViewProvider(config *model.StartConfig) {
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
config.WebViewProvider = goprovider.New()
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package libgopeed
|
||||
import "C"
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
@@ -14,9 +15,17 @@ func Start(cfg string) (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
config.ProductionMode = true
|
||||
applyWebViewProvider(&config)
|
||||
return rest.Start(&config)
|
||||
}
|
||||
|
||||
func Stop() {
|
||||
rest.Stop()
|
||||
}
|
||||
|
||||
func applyWebViewProvider(config *model.StartConfig) {
|
||||
if config == nil || config.WebViewRPCConfig == nil || !config.WebViewRPCConfig.Enabled() {
|
||||
return
|
||||
}
|
||||
config.WebViewProvider = rpcprovider.New(*config.WebViewRPCConfig)
|
||||
}
|
||||
|
||||
+442
-10
@@ -6,13 +6,24 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/webview/goprovider"
|
||||
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
type kvFlag map[string]string
|
||||
|
||||
type webViewConfig struct {
|
||||
provider string
|
||||
network string
|
||||
address string
|
||||
token string
|
||||
}
|
||||
|
||||
func (l *kvFlag) String() string {
|
||||
if l == nil {
|
||||
return ""
|
||||
@@ -40,29 +51,39 @@ func (l *kvFlag) Set(value string) error {
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.Exit(goprovider.RunMainThreadLoop(run))
|
||||
}
|
||||
|
||||
func run() int {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
return 2
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "resolve":
|
||||
if err := runResolve(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
return 1
|
||||
}
|
||||
case "download":
|
||||
if err := runDownload(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
case "eval":
|
||||
if err := runEval(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
return 1
|
||||
}
|
||||
case "-h", "--help", "help":
|
||||
usage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1])
|
||||
usage()
|
||||
os.Exit(2)
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func usage() {
|
||||
@@ -70,15 +91,22 @@ func usage() {
|
||||
|
||||
Usage:
|
||||
extmock resolve [flags] <extension-dir> <url>
|
||||
extmock download [flags] <extension-dir> <url> <output-dir>
|
||||
extmock eval [flags] [script-file]
|
||||
|
||||
Flags:
|
||||
-label key=value Request label, repeatable
|
||||
-setting key=value Extension setting override, repeatable
|
||||
-webview-provider WebView provider: go|rpc
|
||||
-webview-network RPC network when provider=rpc: unix|tcp
|
||||
-webview-address RPC address when provider=rpc
|
||||
-webview-token RPC bearer token when provider=rpc
|
||||
-verb Print download progress while running
|
||||
-pretty Pretty-print JSON output
|
||||
|
||||
Examples:
|
||||
extmock resolve ./ https://www.google.com
|
||||
extmock download ./ https://example.com/video /tmp/extmock-download
|
||||
extmock resolve ./ https://example.com/video -label env=test -setting token=abc
|
||||
extmock eval ./test.js
|
||||
extmock eval -e "new Blob(['hello']).size"
|
||||
@@ -91,8 +119,13 @@ func runResolve(args []string) error {
|
||||
var labels kvFlag
|
||||
var settings kvFlag
|
||||
var pretty bool
|
||||
cfg := webViewConfig{}
|
||||
fs.Var(&labels, "label", "Request label in key=value form (repeatable)")
|
||||
fs.Var(&settings, "setting", "Extension setting override in key=value form (repeatable)")
|
||||
fs.StringVar(&cfg.provider, "webview-provider", "", "WebView provider: go|rpc")
|
||||
fs.StringVar(&cfg.network, "webview-network", "", "WebView RPC network: unix|tcp")
|
||||
fs.StringVar(&cfg.address, "webview-address", "", "WebView RPC address")
|
||||
fs.StringVar(&cfg.token, "webview-token", "", "WebView RPC bearer token")
|
||||
fs.BoolVar(&pretty, "pretty", false, "Pretty-print JSON output")
|
||||
|
||||
flagArgs, positionalArgs, err := splitResolveArgs(args)
|
||||
@@ -115,7 +148,7 @@ func runResolve(args []string) error {
|
||||
}
|
||||
url := positionalArgs[1]
|
||||
|
||||
d, cleanup, err := newExtMockDownloader()
|
||||
d, cleanup, err := newExtMockDownloader(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -140,6 +173,163 @@ func runResolve(args []string) error {
|
||||
return printJSON(result, pretty)
|
||||
}
|
||||
|
||||
type downloadResult struct {
|
||||
Resolve *download.ResolveResult `json:"resolve"`
|
||||
OutputDir string `json:"outputDir"`
|
||||
Files []*downloadedFileResult `json:"files"`
|
||||
}
|
||||
|
||||
type downloadedFileResult struct {
|
||||
Index int `json:"index"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
TaskID string `json:"taskId"`
|
||||
Status base.Status `json:"status"`
|
||||
Size int64 `json:"size"`
|
||||
Req *base.Request `json:"req,omitempty"`
|
||||
}
|
||||
|
||||
func runDownload(args []string) error {
|
||||
fs := flag.NewFlagSet("download", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
var labels kvFlag
|
||||
var settings kvFlag
|
||||
var pretty bool
|
||||
var verbose bool
|
||||
cfg := webViewConfig{}
|
||||
fs.Var(&labels, "label", "Request label in key=value form (repeatable)")
|
||||
fs.Var(&settings, "setting", "Extension setting override in key=value form (repeatable)")
|
||||
fs.StringVar(&cfg.provider, "webview-provider", "", "WebView provider: go|rpc")
|
||||
fs.StringVar(&cfg.network, "webview-network", "", "WebView RPC network: unix|tcp")
|
||||
fs.StringVar(&cfg.address, "webview-address", "", "WebView RPC address")
|
||||
fs.StringVar(&cfg.token, "webview-token", "", "WebView RPC bearer token")
|
||||
fs.BoolVar(&verbose, "verb", false, "Print download progress while running")
|
||||
fs.BoolVar(&verbose, "verbose", false, "Print download progress while running")
|
||||
fs.BoolVar(&pretty, "pretty", false, "Pretty-print JSON output")
|
||||
|
||||
flagArgs, positionalArgs, err := splitDownloadArgs(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fs.Parse(flagArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return fmt.Errorf("unexpected trailing args: %v", fs.Args())
|
||||
}
|
||||
if len(positionalArgs) != 3 {
|
||||
return fmt.Errorf("download expects exactly 3 args: <extension-dir> <url> <output-dir>")
|
||||
}
|
||||
|
||||
extDir, err := filepath.Abs(positionalArgs[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url := positionalArgs[1]
|
||||
outputDir, err := filepath.Abs(positionalArgs[2])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d, cleanup, err := newExtMockDownloader(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
ext, err := d.InstallExtensionByFolder(extDir, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(settings) > 0 {
|
||||
if err := d.UpdateExtensionSettings(ext.Identity, toAnyMap(settings)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
result, err := d.Resolve(&base.Request{
|
||||
URL: url,
|
||||
Labels: map[string]string(labels),
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result == nil || result.Res == nil {
|
||||
return fmt.Errorf("resolve returned empty resource")
|
||||
}
|
||||
if len(result.Res.Files) == 0 {
|
||||
return fmt.Errorf("resolve returned no files")
|
||||
}
|
||||
|
||||
resourceOutputDir := outputDir
|
||||
if result.Res.Name != "" {
|
||||
resourceOutputDir = filepath.Join(outputDir, result.Res.Name)
|
||||
}
|
||||
if err := os.MkdirAll(resourceOutputDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
waiter := newTaskTerminalWaiter(d)
|
||||
summary := &downloadResult{
|
||||
Resolve: result,
|
||||
OutputDir: resourceOutputDir,
|
||||
Files: make([]*downloadedFileResult, 0, len(result.Res.Files)),
|
||||
}
|
||||
|
||||
for idx, file := range result.Res.Files {
|
||||
if file == nil {
|
||||
return fmt.Errorf("file %d is nil", idx)
|
||||
}
|
||||
if file.Req == nil {
|
||||
return fmt.Errorf("file %d request is nil", idx)
|
||||
}
|
||||
|
||||
targetDir := resourceOutputDir
|
||||
if file.Path != "" {
|
||||
targetDir = filepath.Join(resourceOutputDir, file.Path)
|
||||
}
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
taskID, err := d.CreateDirect(file.Req, &base.Options{
|
||||
Path: targetDir,
|
||||
Name: file.Name,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create task for %q failed: %w", file.Name, err)
|
||||
}
|
||||
waiter.Register(taskID)
|
||||
|
||||
summary.Files = append(summary.Files, &downloadedFileResult{
|
||||
Index: idx,
|
||||
Name: file.Name,
|
||||
Path: filepath.Join(targetDir, file.Name),
|
||||
TaskID: taskID,
|
||||
Req: file.Req,
|
||||
})
|
||||
}
|
||||
|
||||
var stopVerbose func()
|
||||
if verbose {
|
||||
stopVerbose = startVerboseTaskListener(d, summary.Files)
|
||||
defer stopVerbose()
|
||||
}
|
||||
|
||||
for _, file := range summary.Files {
|
||||
task, err := waiter.Wait(file.TaskID, 5*time.Minute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download %q failed: %w", file.Name, err)
|
||||
}
|
||||
file.Status = task.Status
|
||||
if info, statErr := os.Stat(file.Path); statErr == nil {
|
||||
file.Size = info.Size()
|
||||
}
|
||||
}
|
||||
|
||||
return printJSON(summary, pretty)
|
||||
}
|
||||
|
||||
func runEval(args []string) error {
|
||||
fs := flag.NewFlagSet("eval", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
@@ -147,8 +337,13 @@ func runEval(args []string) error {
|
||||
script string
|
||||
pretty bool
|
||||
)
|
||||
cfg := webViewConfig{}
|
||||
fs.StringVar(&script, "e", "", "Inline JavaScript to execute")
|
||||
fs.StringVar(&script, "eval", "", "Inline JavaScript to execute")
|
||||
fs.StringVar(&cfg.provider, "webview-provider", "", "WebView provider: go|rpc")
|
||||
fs.StringVar(&cfg.network, "webview-network", "", "WebView RPC network: unix|tcp")
|
||||
fs.StringVar(&cfg.address, "webview-address", "", "WebView RPC address")
|
||||
fs.StringVar(&cfg.token, "webview-token", "", "WebView RPC bearer token")
|
||||
fs.BoolVar(&pretty, "pretty", false, "Pretty-print JSON output")
|
||||
|
||||
flagArgs, positionalArgs, err := splitEvalArgs(args)
|
||||
@@ -171,13 +366,13 @@ func runEval(args []string) error {
|
||||
return fmt.Errorf("eval expects at most 1 script file path")
|
||||
}
|
||||
|
||||
d, cleanup, err := newExtMockDownloader()
|
||||
d, cleanup, err := newExtMockDownloader(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
runtime, err := d.NewExtMockRuntime()
|
||||
runtime, err := d.NewExtensionEngine(newExtMockExtension(), map[string]any{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -207,6 +402,12 @@ func splitResolveArgs(args []string) ([]string, []string, error) {
|
||||
switch {
|
||||
case arg == "-pretty":
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case arg == "-webview-provider", arg == "-webview-network", arg == "-webview-address", arg == "-webview-token":
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, fmt.Errorf("flag %q requires a value", arg)
|
||||
}
|
||||
flagArgs = append(flagArgs, arg, args[i+1])
|
||||
i++
|
||||
case arg == "-label":
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, fmt.Errorf("flag %q requires a value", arg)
|
||||
@@ -223,6 +424,51 @@ func splitResolveArgs(args []string) ([]string, []string, error) {
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case len(arg) > len("-setting=") && arg[:len("-setting=")] == "-setting=":
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case hasFlagValue(arg, "-webview-provider"), hasFlagValue(arg, "-webview-network"),
|
||||
hasFlagValue(arg, "-webview-address"), hasFlagValue(arg, "-webview-token"):
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case len(arg) > 0 && arg[0] == '-':
|
||||
return nil, nil, fmt.Errorf("unknown flag %q", arg)
|
||||
default:
|
||||
positionalArgs = append(positionalArgs, arg)
|
||||
}
|
||||
}
|
||||
return flagArgs, positionalArgs, nil
|
||||
}
|
||||
|
||||
func splitDownloadArgs(args []string) ([]string, []string, error) {
|
||||
var flagArgs []string
|
||||
var positionalArgs []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "-pretty", arg == "-verb", arg == "-verbose":
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case arg == "-webview-provider", arg == "-webview-network", arg == "-webview-address", arg == "-webview-token":
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, fmt.Errorf("flag %q requires a value", arg)
|
||||
}
|
||||
flagArgs = append(flagArgs, arg, args[i+1])
|
||||
i++
|
||||
case arg == "-label":
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, fmt.Errorf("flag %q requires a value", arg)
|
||||
}
|
||||
flagArgs = append(flagArgs, arg, args[i+1])
|
||||
i++
|
||||
case arg == "-setting":
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, fmt.Errorf("flag %q requires a value", arg)
|
||||
}
|
||||
flagArgs = append(flagArgs, arg, args[i+1])
|
||||
i++
|
||||
case len(arg) > len("-label=") && arg[:len("-label=")] == "-label=":
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case len(arg) > len("-setting=") && arg[:len("-setting=")] == "-setting=":
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case hasFlagValue(arg, "-webview-provider"), hasFlagValue(arg, "-webview-network"),
|
||||
hasFlagValue(arg, "-webview-address"), hasFlagValue(arg, "-webview-token"):
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case len(arg) > 0 && arg[0] == '-':
|
||||
return nil, nil, fmt.Errorf("unknown flag %q", arg)
|
||||
default:
|
||||
@@ -240,13 +486,21 @@ func splitEvalArgs(args []string) ([]string, []string, error) {
|
||||
switch {
|
||||
case arg == "-pretty":
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case arg == "-webview-provider", arg == "-webview-network", arg == "-webview-address", arg == "-webview-token":
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, fmt.Errorf("flag %q requires a value", arg)
|
||||
}
|
||||
flagArgs = append(flagArgs, arg, args[i+1])
|
||||
i++
|
||||
case arg == "-e", arg == "--eval":
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, fmt.Errorf("flag %q requires a value", arg)
|
||||
}
|
||||
flagArgs = append(flagArgs, arg, args[i+1])
|
||||
i++
|
||||
case hasFlagValue(arg, "-e"), hasFlagValue(arg, "--eval"):
|
||||
case hasFlagValue(arg, "-e"), hasFlagValue(arg, "--eval"),
|
||||
hasFlagValue(arg, "-webview-provider"), hasFlagValue(arg, "-webview-network"),
|
||||
hasFlagValue(arg, "-webview-address"), hasFlagValue(arg, "-webview-token"):
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case len(arg) > 0 && arg[0] == '-':
|
||||
return nil, nil, fmt.Errorf("unknown flag %q", arg)
|
||||
@@ -269,8 +523,186 @@ func toAnyMap(values kvFlag) map[string]any {
|
||||
return result
|
||||
}
|
||||
|
||||
func newExtMockDownloader() (*download.Downloader, func(), error) {
|
||||
return download.NewExtMockDownloader()
|
||||
func newExtMockDownloader(cfg webViewConfig) (*download.Downloader, func(), error) {
|
||||
webViewProvider, err := newExtMockWebViewProvider(cfg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
d := download.NewDownloader(&download.DownloaderConfig{
|
||||
Storage: download.NewMemStorage(),
|
||||
WebViewProvider: webViewProvider,
|
||||
})
|
||||
if err := d.Setup(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = d.Clear()
|
||||
}
|
||||
return d, cleanup, nil
|
||||
}
|
||||
|
||||
func newExtMockExtension() *download.Extension {
|
||||
return &download.Extension{
|
||||
Name: "extmock",
|
||||
Author: "gopeed",
|
||||
Title: "Gopeed ExtMock Runtime",
|
||||
Version: "0.0.0",
|
||||
DevMode: true,
|
||||
}
|
||||
}
|
||||
|
||||
func newExtMockWebViewProvider(cfg webViewConfig) (enginewebview.Provider, error) {
|
||||
switch cfg.provider {
|
||||
case "", "go":
|
||||
return goprovider.New(), nil
|
||||
case "rpc":
|
||||
rpcCfg, err := resolveRPCConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rpcprovider.New(rpcCfg), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported webview provider %q", cfg.provider)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveRPCConfig(cfg webViewConfig) (enginewebview.RPCConfig, error) {
|
||||
network := cfg.network
|
||||
address := cfg.address
|
||||
if network == "" && address == "" {
|
||||
defaultAddress, err := defaultWebViewRPCUnixSocket()
|
||||
if err != nil {
|
||||
return enginewebview.RPCConfig{}, err
|
||||
}
|
||||
network = "unix"
|
||||
address = defaultAddress
|
||||
}
|
||||
if network == "" || address == "" {
|
||||
return enginewebview.RPCConfig{}, fmt.Errorf("webview rpc requires both network and address")
|
||||
}
|
||||
return enginewebview.RPCConfig{
|
||||
Network: network,
|
||||
Address: address,
|
||||
Token: cfg.token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func defaultWebViewRPCUnixSocket() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(homeDir, "gopeed_webview.sock"), nil
|
||||
}
|
||||
|
||||
type taskTerminalWaiter struct {
|
||||
downloader *download.Downloader
|
||||
}
|
||||
|
||||
func newTaskTerminalWaiter(d *download.Downloader) *taskTerminalWaiter {
|
||||
return &taskTerminalWaiter{downloader: d}
|
||||
}
|
||||
|
||||
func (w *taskTerminalWaiter) Register(id string) {
|
||||
}
|
||||
|
||||
func (w *taskTerminalWaiter) Wait(id string, timeout time.Duration) (*download.Task, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if task, ok := w.currentTerminalTask(id); ok {
|
||||
return task, w.taskError(task)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return nil, fmt.Errorf("timeout waiting for task %s", id)
|
||||
}
|
||||
|
||||
func (w *taskTerminalWaiter) currentTerminalTask(id string) (*download.Task, bool) {
|
||||
task := w.downloader.GetTask(id)
|
||||
if task == nil {
|
||||
return nil, false
|
||||
}
|
||||
if task.Status != base.DownloadStatusDone && task.Status != base.DownloadStatusError {
|
||||
return nil, false
|
||||
}
|
||||
return task, true
|
||||
}
|
||||
|
||||
func (w *taskTerminalWaiter) taskError(task *download.Task) error {
|
||||
if task == nil {
|
||||
return fmt.Errorf("task is nil")
|
||||
}
|
||||
if task.Status == base.DownloadStatusError {
|
||||
return fmt.Errorf("task %s finished with status %s", task.ID, task.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func startVerboseTaskListener(d *download.Downloader, files []*downloadedFileResult) func() {
|
||||
tracked := make(map[string]string, len(files))
|
||||
for _, file := range files {
|
||||
tracked[file.TaskID] = file.Name
|
||||
}
|
||||
d.Listener(func(event *download.Event) {
|
||||
if event == nil || event.Task == nil {
|
||||
return
|
||||
}
|
||||
name, ok := tracked[event.Task.ID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch event.Key {
|
||||
case download.EventKeyStart, download.EventKeyProgress, download.EventKeyDone, download.EventKeyError:
|
||||
printVerboseTaskEvent(event, name)
|
||||
}
|
||||
})
|
||||
return func() {}
|
||||
}
|
||||
|
||||
func printVerboseTaskEvent(event *download.Event, name string) {
|
||||
task := event.Task
|
||||
ts := time.Now().Format("15:04:05.000")
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
total := int64(0)
|
||||
if task.Meta != nil && task.Meta.Res != nil {
|
||||
total = task.Meta.Res.Size
|
||||
}
|
||||
downloaded := int64(0)
|
||||
speed := int64(0)
|
||||
if task.Progress != nil {
|
||||
downloaded = task.Progress.Downloaded
|
||||
speed = task.Progress.Speed
|
||||
}
|
||||
if event.Key == download.EventKeyError && event.Err != nil {
|
||||
fmt.Fprintf(
|
||||
os.Stderr,
|
||||
"[%s] event=%s task=%s status=%s downloaded=%d total=%d speed=%d name=%q err=%v\n",
|
||||
ts,
|
||||
event.Key,
|
||||
task.ID,
|
||||
task.Status,
|
||||
downloaded,
|
||||
total,
|
||||
speed,
|
||||
name,
|
||||
event.Err,
|
||||
)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(
|
||||
os.Stderr,
|
||||
"[%s] event=%s task=%s status=%s downloaded=%d total=%d speed=%d name=%q\n",
|
||||
ts,
|
||||
event.Key,
|
||||
task.ID,
|
||||
task.Status,
|
||||
downloaded,
|
||||
total,
|
||||
speed,
|
||||
name,
|
||||
)
|
||||
}
|
||||
|
||||
func printJSON(v any, pretty bool) error {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
module github.com/GopeedLab/gopeed
|
||||
|
||||
go 1.24.9
|
||||
|
||||
toolchain go1.24.11
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5
|
||||
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||
github.com/bodgit/sevenzip v1.6.1
|
||||
@@ -17,6 +16,7 @@ require (
|
||||
github.com/matoous/go-nanoid/v2 v2.0.0
|
||||
github.com/mattn/go-ieproxy v0.0.12
|
||||
github.com/mholt/archives v0.1.5
|
||||
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
|
||||
github.com/rs/zerolog v1.31.0
|
||||
@@ -45,7 +45,6 @@ require (
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.0.1 // indirect
|
||||
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.0 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
|
||||
@@ -23,6 +23,12 @@ filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmG
|
||||
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260416010719-fc58e7d53769 h1:iuMR7aHvckis8PKYUDXDa8elcmf34GksWpaYak1EYKw=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260416010719-fc58e7d53769/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260423023243-b3f067ccab2f h1:yu//jtofUpHZB+IpB1xIH8z4o4JsFJ6pMvgA1Ww/ElY=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260423023243-b3f067ccab2f/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5 h1:86LVTaNDM/j/uTAD2HedkXNO9LIQlt642VAdbU/0D/M=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
@@ -386,8 +392,6 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/monkeyWie/goed2k v0.0.0-20260317094144-6e18d43056e5 h1:tEOucyKJzFsCz5Gr41jFHj8i2g1zemTyS4uyErJgFHc=
|
||||
github.com/monkeyWie/goed2k v0.0.0-20260317094144-6e18d43056e5/go.mod h1:Ry2y1QlzerUgA1hVmExBdXXzE4Sjk1M7w0nSh6dhDOg=
|
||||
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447 h1:88kRsNwkDKPA4NzRUWdOoIL7SyMNDwtmAxzShKBvKTI=
|
||||
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447/go.mod h1:Ry2y1QlzerUgA1hVmExBdXXzE4Sjk1M7w0nSh6dhDOg=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
|
||||
@@ -15,7 +15,8 @@ The `gblob` protocol provides an internal object-URL style transport so extensio
|
||||
The extension-facing model is intentionally close to the browser:
|
||||
|
||||
- `URL.createObjectURL(new Blob(...))`
|
||||
- `URL.createObjectURL(writableStream)`
|
||||
- `URL.createObjectURL(readableStream)`
|
||||
- `URL.createObjectURL(openReadable)`
|
||||
- `URL.revokeObjectURL(url)`
|
||||
|
||||
The returned URL looks like:
|
||||
@@ -29,7 +30,7 @@ gblob:<id>
|
||||
## Design Goals
|
||||
|
||||
- make extension-generated data download as a normal task
|
||||
- support both eager `Blob` data and incremental `WritableStream` data
|
||||
- support eager `Blob` data, one-shot `ReadableStream` data, and resumable opener functions
|
||||
- avoid large in-memory buffering by spooling to disk
|
||||
- keep pause/continue behavior intuitive
|
||||
- allow an extension to recreate a new `gblob` URL after source expiration
|
||||
@@ -43,7 +44,7 @@ The implementation has two layers in this directory:
|
||||
|
||||
The end-to-end flow is:
|
||||
|
||||
1. JavaScript creates a `Blob` or `WritableStream` object URL.
|
||||
1. JavaScript creates a `Blob`, `ReadableStream`, or opener-function object URL.
|
||||
2. Go registers a `gblob` source and assigns it a unique `gblob:<id>` URL.
|
||||
3. Source bytes are written into a local spool file.
|
||||
4. The downloader resolves the `gblob` URL through the `gblob` fetcher.
|
||||
@@ -67,7 +68,8 @@ Each source tracks:
|
||||
Key behaviors:
|
||||
|
||||
- `Blob` sources are written once and immediately closed.
|
||||
- `WritableStream` sources stay open until the writer closes or aborts.
|
||||
- `ReadableStream` sources are bridged into the internal writable-source pipeline.
|
||||
- opener-function sources are opened lazily and can be reopened from an offset.
|
||||
- readers wait on a per-source notification channel when no new bytes are available yet.
|
||||
- revocation only prevents future use; cleanup happens when the source is no longer pinned.
|
||||
|
||||
@@ -82,9 +84,9 @@ The fetcher treats `gblob` as a local file-backed download source.
|
||||
Behavior differences by source type:
|
||||
|
||||
- `Blob`: resolved size is the current written size
|
||||
- `WritableStream`: resolved size is reported as `0` until the writer closes
|
||||
- `ReadableStream` / opener function: resolved size is whatever the extension declared; if unknown, it stays `0` until download completion updates the final size
|
||||
|
||||
`Range` is always `false`.
|
||||
`Range` is `true` only when the extension provides a resumable opener and marks the resource as ranged.
|
||||
|
||||
### Start
|
||||
|
||||
@@ -123,7 +125,12 @@ For the same `gblob` id:
|
||||
|
||||
- the partially downloaded output file is kept
|
||||
- the fetcher resumes from the current file size
|
||||
- `WritableStream` producers may continue writing while the task is paused
|
||||
- opener-function sources can be reopened from the current offset
|
||||
|
||||
For non-reopenable one-shot `ReadableStream` sources:
|
||||
|
||||
- a normal in-process pause/continue only works while the same in-memory source is still alive
|
||||
- they are not suitable for cross-session or reconstructable range resume
|
||||
|
||||
### Process restart
|
||||
|
||||
@@ -172,19 +179,19 @@ gopeed.events.onResolve(async function (ctx) {
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Create a streaming object URL
|
||||
### 2. Create a one-shot `ReadableStream` object URL
|
||||
|
||||
```js
|
||||
gopeed.events.onResolve(async function (ctx) {
|
||||
const stream = new TransformStream();
|
||||
const writer = stream.writable.getWriter();
|
||||
const url = URL.createObjectURL(stream.writable);
|
||||
|
||||
(async () => {
|
||||
await writer.write(new TextEncoder().encode("line 1\n"));
|
||||
await writer.write(new TextEncoder().encode("line 2\n"));
|
||||
await writer.close();
|
||||
})();
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode("line 1\n"));
|
||||
controller.enqueue(encoder.encode("line 2\n"));
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
const url = URL.createObjectURL(stream);
|
||||
|
||||
ctx.res = {
|
||||
name: "stream-example",
|
||||
@@ -198,14 +205,53 @@ gopeed.events.onResolve(async function (ctx) {
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Recover in `onError`
|
||||
### 3. Create a resumable opener object URL
|
||||
|
||||
```js
|
||||
gopeed.events.onResolve(async function (ctx) {
|
||||
const target = "https://example.com/file.bin";
|
||||
|
||||
const openReadable = async (offset) => {
|
||||
const headers = {};
|
||||
if (offset > 0) {
|
||||
headers.Range = `bytes=${offset}-`;
|
||||
}
|
||||
const response = await fetch(target, { headers });
|
||||
if (response.status !== 200 || response.status !== 206) {
|
||||
throw new Error(`unexpected response status: ${response.status}, expected ${expectedStatus}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("empty response body");
|
||||
}
|
||||
return response.body;
|
||||
};
|
||||
|
||||
ctx.res = {
|
||||
name: "range-example",
|
||||
range: true,
|
||||
files: [
|
||||
{
|
||||
name: "file.bin",
|
||||
req: { url: URL.createObjectURL(openReadable) }
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
Important:
|
||||
|
||||
- opener functions must return a `ReadableStream`
|
||||
- do not return `response.body.getReader()`
|
||||
- use opener functions for resumable / range-aware sources
|
||||
- validate HTTP status codes explicitly:
|
||||
first request usually expects `200`, resumed range requests usually expect `206`
|
||||
|
||||
### 4. Recover in `onError`
|
||||
|
||||
```js
|
||||
gopeed.events.onError(async function (ctx) {
|
||||
const req = ctx.task.meta.req;
|
||||
if (!req.rawUrl) return;
|
||||
if (!req.url.startsWith("gblob:")) return;
|
||||
|
||||
req.url = URL.createObjectURL(new Blob(["recovered\n"]));
|
||||
ctx.task.continue();
|
||||
});
|
||||
@@ -215,11 +261,11 @@ This creates a new `gblob` id, so the downloader restarts from byte `0`.
|
||||
|
||||
## Limitations
|
||||
|
||||
- `gblob` is not a network protocol and does not support range requests.
|
||||
- `gblob` is not a network protocol. Range support only exists when the extension recreates bytes through a resumable opener.
|
||||
- unfinished `gblob` sources do not survive process restarts.
|
||||
- recovery after restart depends on extension logic recreating a new source.
|
||||
- the current restart behavior is full restart on source-id change, not byte-range recovery across recreated sources.
|
||||
- `WritableStream` tasks with unknown total size report size `0` until the writer closes.
|
||||
- one-shot `ReadableStream` tasks with unknown total size report size `0` until completion updates the final size.
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -231,3 +277,9 @@ The most important rule is simple:
|
||||
|
||||
- same `gblob` id means resume
|
||||
- different `gblob` id means restart
|
||||
|
||||
And for extension authors:
|
||||
|
||||
- use `ReadableStream` for one-shot streaming content
|
||||
- use opener functions for resumable content
|
||||
- opener functions must return `ReadableStream`, not a reader
|
||||
|
||||
@@ -62,15 +62,21 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
if name == "" {
|
||||
name = src.ID
|
||||
}
|
||||
size := src.Snapshot().Written
|
||||
snapshot := src.Snapshot()
|
||||
size := snapshot.Written
|
||||
if snapshot.DeclaredSize > 0 {
|
||||
size = snapshot.DeclaredSize
|
||||
}
|
||||
if src.Type == SourceTypeWritableStream {
|
||||
// Writable streams may continue producing bytes after Resolve,
|
||||
// so treat the total size as unknown until the writer closes.
|
||||
size = 0
|
||||
if snapshot.DeclaredSize == 0 {
|
||||
size = 0
|
||||
}
|
||||
}
|
||||
f.meta.Res = &base.Resource{
|
||||
Size: size,
|
||||
Range: false,
|
||||
Range: snapshot.Range,
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: name,
|
||||
@@ -102,45 +108,90 @@ func (f *Fetcher) Start() error {
|
||||
f.cancel = cancel
|
||||
f.runDone = runDone
|
||||
f.mu.Unlock()
|
||||
cleanupStartErr := func(err error) error {
|
||||
f.mu.Lock()
|
||||
if f.runDone == runDone {
|
||||
f.runDone = nil
|
||||
f.cancel = nil
|
||||
}
|
||||
f.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
path := f.meta.SingleFilepath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
target, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
offset := int64(0)
|
||||
if f.lastSourceID != "" && f.lastSourceID != currentSourceID {
|
||||
allowResumeAcrossSource := f.meta.Res != nil && f.meta.Res.Range
|
||||
if f.lastSourceID != "" && f.lastSourceID != currentSourceID && !allowResumeAcrossSource {
|
||||
if err := target.Truncate(0); err != nil {
|
||||
target.Close()
|
||||
return err
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
if _, err := target.Seek(0, io.SeekStart); err != nil {
|
||||
target.Close()
|
||||
return err
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
} else {
|
||||
stat, err := target.Stat()
|
||||
if err != nil {
|
||||
target.Close()
|
||||
return err
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
offset = stat.Size()
|
||||
if _, err := target.Seek(offset, io.SeekStart); err != nil {
|
||||
target.Close()
|
||||
return err
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
}
|
||||
f.lastSourceID = currentSourceID
|
||||
f.downloaded.Store(offset)
|
||||
|
||||
go f.copyLoop(ctx, target, offset)
|
||||
sourceOffset := offset
|
||||
src, err := f.registry.Get(f.meta.Req.URL)
|
||||
if err != nil {
|
||||
target.Close()
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
snapshot := src.Snapshot()
|
||||
if snapshot.Written == 0 && !snapshot.Started {
|
||||
if f.meta.Res != nil && f.meta.Res.Range && offset > 0 {
|
||||
if err := f.registry.Reopen(f.meta.Req.URL, offset); err != nil {
|
||||
target.Close()
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
sourceOffset = 0
|
||||
} else if err := f.registry.StartSource(f.meta.Req.URL); err != nil {
|
||||
target.Close()
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
src, err = f.registry.Get(f.meta.Req.URL)
|
||||
if err != nil {
|
||||
target.Close()
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
snapshot = src.Snapshot()
|
||||
}
|
||||
if f.meta.Res != nil && f.meta.Res.Range && (snapshot.State == SourceStateAborted || snapshot.State == SourceStateFailed) {
|
||||
if err := f.registry.Reopen(f.meta.Req.URL, offset); err != nil {
|
||||
target.Close()
|
||||
return cleanupStartErr(err)
|
||||
}
|
||||
sourceOffset = 0
|
||||
}
|
||||
|
||||
go f.copyLoop(ctx, target, offset, sourceOffset)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, offset int64) {
|
||||
func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, targetOffset, sourceOffset int64) {
|
||||
var finalErr error
|
||||
|
||||
defer target.Close()
|
||||
defer func() {
|
||||
f.mu.Lock()
|
||||
@@ -161,85 +212,75 @@ func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, offset int64) {
|
||||
if paused {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case f.doneCh <- finalErr:
|
||||
default:
|
||||
}
|
||||
}()
|
||||
|
||||
src, err := f.registry.Get(f.meta.Req.URL)
|
||||
if err != nil {
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
finalErr = err
|
||||
return
|
||||
}
|
||||
sourceFile, err := os.Open(src.Path)
|
||||
if err != nil {
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
finalErr = err
|
||||
return
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
if err := f.registry.WaitForReadable(ctx, f.meta.Req.URL, offset); err != nil {
|
||||
if err := f.registry.WaitForReadable(ctx, f.meta.Req.URL, sourceOffset); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
finalErr = err
|
||||
return
|
||||
}
|
||||
|
||||
snapshot, serr := f.registry.Get(f.meta.Req.URL)
|
||||
if serr != nil {
|
||||
select {
|
||||
case f.doneCh <- serr:
|
||||
default:
|
||||
}
|
||||
finalErr = serr
|
||||
return
|
||||
}
|
||||
current := snapshot.Snapshot()
|
||||
for offset < current.Written {
|
||||
remain := current.Written - offset
|
||||
for sourceOffset < current.Written {
|
||||
remain := current.Written - sourceOffset
|
||||
readSize := int64(len(buf))
|
||||
if remain < readSize {
|
||||
readSize = remain
|
||||
}
|
||||
n, err := sourceFile.ReadAt(buf[:readSize], offset)
|
||||
n, err := sourceFile.ReadAt(buf[:readSize], sourceOffset)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
finalErr = err
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
if _, err := target.Write(buf[:n]); err != nil {
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
finalErr = err
|
||||
return
|
||||
}
|
||||
offset += int64(n)
|
||||
f.downloaded.Store(offset)
|
||||
sourceOffset += int64(n)
|
||||
targetOffset += int64(n)
|
||||
f.downloaded.Store(targetOffset)
|
||||
}
|
||||
|
||||
if current.State == SourceStateClosed && offset >= current.Written {
|
||||
if current.State == SourceStateClosed && sourceOffset >= current.Written {
|
||||
if f.meta.Res != nil {
|
||||
f.meta.Res.Size = current.Written
|
||||
f.meta.Res.Files[0].Size = current.Written
|
||||
}
|
||||
select {
|
||||
case f.doneCh <- nil:
|
||||
default:
|
||||
if f.meta.Res.Size > 0 && targetOffset < f.meta.Res.Size {
|
||||
finalErr = io.ErrUnexpectedEOF
|
||||
return
|
||||
}
|
||||
if f.meta.Res.Size == 0 {
|
||||
f.meta.Res.Size = targetOffset
|
||||
}
|
||||
f.meta.Res.Files[0].Size = f.meta.Res.Size
|
||||
}
|
||||
finalErr = nil
|
||||
return
|
||||
}
|
||||
if current.State == SourceStateAborted || current.State == SourceStateFailed {
|
||||
@@ -247,10 +288,7 @@ func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, offset int64) {
|
||||
if err == nil {
|
||||
err = ErrSourceAborted
|
||||
}
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
finalErr = err
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package gblob
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
internalfetcher "github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
func TestFetcherClosedSourceBeforeExpectedSizeReturnsError(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
url, err := registry.CreateWritableStream(&CreateWritableStreamOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Write(url, []byte("abc")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.CloseSource(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
filePath := filepath.Join(downloadDir, "partial.bin")
|
||||
f := &Fetcher{
|
||||
registry: registry,
|
||||
meta: &internalfetcher.FetcherMeta{
|
||||
Req: &base.Request{URL: url},
|
||||
Res: &base.Resource{
|
||||
Size: 10,
|
||||
Range: true,
|
||||
Files: []*base.FileInfo{{Name: "partial.bin", Req: &base.Request{URL: url}}},
|
||||
},
|
||||
Opts: &base.Options{Path: downloadDir, Name: "partial.bin"},
|
||||
},
|
||||
}
|
||||
f.Setup(controller.NewController())
|
||||
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-f.doneCh:
|
||||
if !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("expected unexpected EOF, got %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for fetcher completion")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "abc" {
|
||||
t.Fatalf("unexpected partial file content: %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherClosedUnknownSizeSourceCompletesSuccessfully(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
url, err := registry.CreateWritableStream(&CreateWritableStreamOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Write(url, []byte("abcdef")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.CloseSource(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
filePath := filepath.Join(downloadDir, "stream.bin")
|
||||
f := &Fetcher{
|
||||
registry: registry,
|
||||
meta: &internalfetcher.FetcherMeta{
|
||||
Req: &base.Request{URL: url},
|
||||
Res: &base.Resource{
|
||||
Size: 0,
|
||||
Range: false,
|
||||
Files: []*base.FileInfo{{Name: "stream.bin", Req: &base.Request{URL: url}}},
|
||||
},
|
||||
Opts: &base.Options{Path: downloadDir, Name: "stream.bin"},
|
||||
},
|
||||
}
|
||||
f.Setup(controller.NewController())
|
||||
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-f.doneCh:
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil completion error, got %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for fetcher completion")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "abcdef" {
|
||||
t.Fatalf("unexpected stream file content: %q", string(data))
|
||||
}
|
||||
if got := f.meta.Res.Size; got != int64(len(data)) {
|
||||
t.Fatalf("unexpected resolved size: got %d want %d", got, len(data))
|
||||
}
|
||||
if got := f.meta.Res.Files[0].Size; got != int64(len(data)) {
|
||||
t.Fatalf("unexpected resolved file size: got %d want %d", got, len(data))
|
||||
}
|
||||
}
|
||||
@@ -42,11 +42,21 @@ type SessionRef interface {
|
||||
Release()
|
||||
}
|
||||
|
||||
type ResumeFunc func(offset int64) error
|
||||
|
||||
type CreateWritableStreamOptions struct {
|
||||
Session SessionRef
|
||||
Reopenable bool
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Written int64
|
||||
State SourceState
|
||||
Err error
|
||||
WaitCh <-chan struct{}
|
||||
Written int64
|
||||
DeclaredSize int64
|
||||
Range bool
|
||||
Started bool
|
||||
State SourceState
|
||||
Err error
|
||||
WaitCh <-chan struct{}
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
@@ -66,16 +76,24 @@ type Source struct {
|
||||
waitCh chan struct{}
|
||||
session SessionRef
|
||||
sessionReleased bool
|
||||
reopenable bool
|
||||
rangeEnabled bool
|
||||
declaredSize int64
|
||||
started bool
|
||||
reopen ResumeFunc
|
||||
}
|
||||
|
||||
func (s *Source) Snapshot() Snapshot {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return Snapshot{
|
||||
Written: s.written,
|
||||
State: s.state,
|
||||
Err: s.err,
|
||||
WaitCh: s.waitCh,
|
||||
Written: s.written,
|
||||
DeclaredSize: s.declaredSize,
|
||||
Range: s.rangeEnabled,
|
||||
Started: s.started,
|
||||
State: s.state,
|
||||
Err: s.err,
|
||||
WaitCh: s.waitCh,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +140,13 @@ func (s *Source) releaseSessionLocked() {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Source) retainSessionLocked() {
|
||||
if s.session != nil && s.sessionReleased {
|
||||
s.session.Retain()
|
||||
s.sessionReleased = false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Source) write(data []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -146,7 +171,9 @@ func (s *Source) write(data []byte) error {
|
||||
s.err = err
|
||||
s.file.Close()
|
||||
s.file = nil
|
||||
s.releaseSessionLocked()
|
||||
if !s.rangeEnabled {
|
||||
s.releaseSessionLocked()
|
||||
}
|
||||
s.notifyLocked()
|
||||
return err
|
||||
}
|
||||
@@ -187,7 +214,9 @@ func (s *Source) abort(err error) error {
|
||||
_ = s.file.Close()
|
||||
s.file = nil
|
||||
}
|
||||
s.releaseSessionLocked()
|
||||
if !s.rangeEnabled {
|
||||
s.releaseSessionLocked()
|
||||
}
|
||||
s.notifyLocked()
|
||||
return nil
|
||||
}
|
||||
@@ -196,6 +225,18 @@ func (s *Source) revoke() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.revoked = true
|
||||
if s.state == SourceStateOpen {
|
||||
s.state = SourceStateAborted
|
||||
if s.err == nil {
|
||||
s.err = ErrSourceRevoked
|
||||
}
|
||||
if s.file != nil {
|
||||
_ = s.file.Close()
|
||||
s.file = nil
|
||||
}
|
||||
s.releaseSessionLocked()
|
||||
s.notifyLocked()
|
||||
}
|
||||
return s.shouldCleanupLocked()
|
||||
}
|
||||
|
||||
@@ -229,6 +270,13 @@ type Registry struct {
|
||||
sources map[string]*Source
|
||||
}
|
||||
|
||||
func (r *Registry) Dir() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.dir
|
||||
}
|
||||
|
||||
func NewRegistry(baseDir string) *Registry {
|
||||
dir := baseDir
|
||||
if dir == "" {
|
||||
@@ -258,7 +306,7 @@ func ParseURL(raw string) (string, error) {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *Registry) createSource(sourceType SourceType, contentType string, session SessionRef) (*Source, error) {
|
||||
func (r *Registry) createSource(sourceType SourceType, contentType string, session SessionRef, reopenable bool, declaredSize int64) (*Source, error) {
|
||||
if err := os.MkdirAll(r.dir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -275,15 +323,19 @@ func (r *Registry) createSource(sourceType SourceType, contentType string, sessi
|
||||
session.Retain()
|
||||
}
|
||||
src := &Source{
|
||||
ID: id,
|
||||
URL: BuildURL(id),
|
||||
Path: filePath,
|
||||
Type: sourceType,
|
||||
ContentType: contentType,
|
||||
file: file,
|
||||
state: SourceStateOpen,
|
||||
waitCh: make(chan struct{}),
|
||||
session: session,
|
||||
ID: id,
|
||||
URL: BuildURL(id),
|
||||
Path: filePath,
|
||||
Type: sourceType,
|
||||
ContentType: contentType,
|
||||
file: file,
|
||||
state: SourceStateOpen,
|
||||
waitCh: make(chan struct{}),
|
||||
session: session,
|
||||
reopenable: reopenable,
|
||||
rangeEnabled: reopenable,
|
||||
declaredSize: declaredSize,
|
||||
started: !reopenable,
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.sources[id] = src
|
||||
@@ -292,7 +344,7 @@ func (r *Registry) createSource(sourceType SourceType, contentType string, sessi
|
||||
}
|
||||
|
||||
func (r *Registry) CreateBlob(data []byte, contentType string) (string, error) {
|
||||
src, err := r.createSource(SourceTypeBlob, contentType, nil)
|
||||
src, err := r.createSource(SourceTypeBlob, contentType, nil, false, int64(len(data)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -307,14 +359,59 @@ func (r *Registry) CreateBlob(data []byte, contentType string) (string, error) {
|
||||
return src.URL, nil
|
||||
}
|
||||
|
||||
func (r *Registry) CreateWritableStream(session SessionRef) (string, error) {
|
||||
src, err := r.createSource(SourceTypeWritableStream, "", session)
|
||||
func (r *Registry) CreateWritableStream(opts *CreateWritableStreamOptions) (string, error) {
|
||||
if opts == nil {
|
||||
opts = &CreateWritableStreamOptions{}
|
||||
}
|
||||
src, err := r.createSource(SourceTypeWritableStream, "", opts.Session, opts.Reopenable, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return src.URL, nil
|
||||
}
|
||||
|
||||
func (r *Registry) SetResumeOpener(raw string, reopen ResumeFunc) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src.mu.Lock()
|
||||
defer src.mu.Unlock()
|
||||
if !src.reopenable {
|
||||
return nil
|
||||
}
|
||||
src.reopen = reopen
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) SetRange(raw string, enabled bool) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src.mu.Lock()
|
||||
src.rangeEnabled = enabled
|
||||
if !enabled && (src.state == SourceStateAborted || src.state == SourceStateFailed) {
|
||||
src.releaseSessionLocked()
|
||||
}
|
||||
src.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) SetSize(raw string, size int64) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src.mu.Lock()
|
||||
if size < 0 {
|
||||
size = 0
|
||||
}
|
||||
src.declaredSize = size
|
||||
src.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) get(raw string) (*Source, error) {
|
||||
id, err := ParseURL(raw)
|
||||
if err != nil {
|
||||
@@ -405,10 +502,80 @@ func (r *Registry) WaitForReadable(ctx context.Context, raw string, offset int64
|
||||
return src.waitForReadable(ctx, offset)
|
||||
}
|
||||
|
||||
func (r *Registry) Reopen(raw string, offset int64) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
src.mu.Lock()
|
||||
if !src.reopenable || src.reopen == nil {
|
||||
src.mu.Unlock()
|
||||
return ErrSourceNotFound
|
||||
}
|
||||
if src.revoked {
|
||||
src.mu.Unlock()
|
||||
return ErrSourceRevoked
|
||||
}
|
||||
if src.file != nil {
|
||||
_ = src.file.Close()
|
||||
src.file = nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(src.Path), 0755); err != nil {
|
||||
src.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
file, err := os.Create(src.Path)
|
||||
if err != nil {
|
||||
src.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
src.file = file
|
||||
src.written = 0
|
||||
src.state = SourceStateOpen
|
||||
src.err = nil
|
||||
src.started = true
|
||||
src.retainSessionLocked()
|
||||
reopen := src.reopen
|
||||
src.mu.Unlock()
|
||||
|
||||
if err := reopen(offset); err != nil {
|
||||
src.mu.Lock()
|
||||
if src.file != nil {
|
||||
_ = src.file.Close()
|
||||
src.file = nil
|
||||
}
|
||||
src.state = SourceStateFailed
|
||||
src.err = err
|
||||
src.releaseSessionLocked()
|
||||
src.notifyLocked()
|
||||
src.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) StartSource(raw string) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src.mu.Lock()
|
||||
if !src.reopenable || src.started {
|
||||
src.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
src.mu.Unlock()
|
||||
return r.Reopen(raw, 0)
|
||||
}
|
||||
|
||||
func (r *Registry) cleanupIfNeeded(src *Source) {
|
||||
src.mu.Lock()
|
||||
shouldCleanup := src.shouldCleanupLocked()
|
||||
file := src.file
|
||||
if shouldCleanup {
|
||||
src.releaseSessionLocked()
|
||||
}
|
||||
src.mu.Unlock()
|
||||
if !shouldCleanup {
|
||||
return
|
||||
@@ -443,5 +610,8 @@ func (r *Registry) Close() error {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if err := os.RemoveAll(r.dir); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
lastErr = err
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package gblob
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegistry_RevokeOpenWritableStreamAbortsAndCleansUpAfterUnpin(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
url, err := registry.CreateWritableStream(&CreateWritableStreamOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Pin(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
src, err := registry.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state := src.Snapshot().State; state != SourceStateOpen {
|
||||
t.Fatalf("expected initial open state, got %s", state)
|
||||
}
|
||||
|
||||
if err := registry.Revoke(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
src, err = registry.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := src.Snapshot()
|
||||
if snapshot.State != SourceStateAborted {
|
||||
t.Fatalf("expected revoked open source to become aborted, got %s", snapshot.State)
|
||||
}
|
||||
if !errors.Is(snapshot.Err, ErrSourceRevoked) {
|
||||
t.Fatalf("expected revoke error, got %v", snapshot.Err)
|
||||
}
|
||||
|
||||
registry.Unpin(url)
|
||||
if _, err := registry.Get(url); !errors.Is(err, ErrSourceNotFound) {
|
||||
t.Fatalf("expected source cleanup after unpin, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -376,7 +376,7 @@ func TestFetcher_DownloadError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetcher_DownloadLimit(t *testing.T) {
|
||||
listener := test.StartTestLimitServer(4, 0)
|
||||
listener := test.StartTestConLimitServer(4)
|
||||
defer listener.Close()
|
||||
|
||||
downloadNormal(listener, 1, t)
|
||||
@@ -614,7 +614,7 @@ func TestFetcher_SlowStartExpansion(t *testing.T) {
|
||||
os.Remove(test.DownloadFile)
|
||||
|
||||
// Use 100ns delay per byte for faster test (~10MB/s theoretical)
|
||||
listener := test.StartTestSlowStartServer(100 * time.Nanosecond)
|
||||
listener := test.StartTestLowSpeedServer(100 * time.Nanosecond)
|
||||
|
||||
// Ensure cleanup happens before next subtest
|
||||
cleanup := func() {
|
||||
@@ -758,7 +758,7 @@ func TestFetcher_AsyncPrefetch(t *testing.T) {
|
||||
t.Run("PrefetchPartial", func(t *testing.T) {
|
||||
// Use slow server with 100 nanosecond delay per byte
|
||||
// This means ~10MB/s speed, so 100ms should download ~1MB
|
||||
listener := test.StartTestSlowStartServer(100 * time.Nanosecond)
|
||||
listener := test.StartTestLowSpeedServer(100 * time.Nanosecond)
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
|
||||
+12
-19
@@ -433,9 +433,8 @@ func StartTestExpiringRedirectServer(requestsBeforeExpire int32, delayPerByte ti
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestSlowStartServer creates a server with configurable delay per request
|
||||
// This allows testing slow-start connection expansion to reach max connections
|
||||
func StartTestSlowStartServer(delayPerByte time.Duration) net.Listener {
|
||||
// StartTestLowSpeedServer creates a server with configurable delay per request
|
||||
func StartTestLowSpeedServer(delayPerByte time.Duration) net.Listener {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
@@ -484,10 +483,9 @@ func slowCopyNWithDelay(sl *shutdownListener, dst io.Writer, src io.Reader, n in
|
||||
}
|
||||
}
|
||||
|
||||
// StartTestLimitServer connections limit server
|
||||
func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
|
||||
// StartTestConLimitServer connections limit server
|
||||
func StartTestConLimitServer(maxConnections int32) net.Listener {
|
||||
var connections atomic.Int32
|
||||
var slowOnce atomic.Bool
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
@@ -500,19 +498,11 @@ func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
|
||||
writer.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
|
||||
// First request intentionally delays the first write to trigger a single read timeout,
|
||||
// subsequent requests respond at normal speed.
|
||||
useInitialDelay := delay > 0 && !slowOnce.Swap(true)
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
if useInitialDelay {
|
||||
slowCopyAfterDelay(sl, writer, file, n, delay)
|
||||
return
|
||||
}
|
||||
slowCopyN(sl, writer, file, n, 0)
|
||||
},
|
||||
)
|
||||
@@ -711,10 +701,13 @@ func rangeFileHandle(writer http.ResponseWriter, request *http.Request, modifyEn
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
end, err := strconv.ParseInt(s[1], 10, 64)
|
||||
if err != nil {
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
end := int64(BuildSize - 1)
|
||||
if s[1] != "" {
|
||||
end, err = strconv.ParseInt(s[1], 10, 64)
|
||||
if err != nil {
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
}
|
||||
if start < 0 || end < 0 || start > end {
|
||||
writer.WriteHeader(400)
|
||||
@@ -880,7 +873,7 @@ func StartSocks5Server(usr, pwd string) net.Listener {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0") // 你可以根据需要更改监听地址
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build darwin
|
||||
|
||||
package goprovider
|
||||
|
||||
var mainThreadTasks chan func()
|
||||
|
||||
func RunMainThreadLoop(run func() int) int {
|
||||
if mainThreadTasks != nil {
|
||||
return run()
|
||||
}
|
||||
|
||||
mainThreadTasks = make(chan func())
|
||||
resultCh := make(chan int, 1)
|
||||
|
||||
go func() {
|
||||
resultCh <- run()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case task := <-mainThreadTasks:
|
||||
task()
|
||||
case code := <-resultCh:
|
||||
mainThreadTasks = nil
|
||||
return code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func postMainThreadTask(task func()) bool {
|
||||
if mainThreadTasks == nil {
|
||||
return false
|
||||
}
|
||||
mainThreadTasks <- task
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !darwin
|
||||
|
||||
package goprovider
|
||||
|
||||
func RunMainThreadLoop(run func() int) int {
|
||||
return run()
|
||||
}
|
||||
|
||||
func postMainThreadTask(func()) bool {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
//go:build cgo
|
||||
|
||||
package goprovider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
runtimepkg "runtime"
|
||||
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
webview "github.com/GopeedLab/webview_go"
|
||||
)
|
||||
|
||||
type Provider struct{}
|
||||
|
||||
func New() enginewebview.Provider {
|
||||
return &Provider{}
|
||||
}
|
||||
|
||||
func (p *Provider) IsAvailable() bool {
|
||||
return webview.IsAvailable()
|
||||
}
|
||||
|
||||
func (p *Provider) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) {
|
||||
pw := newPageWrapper(opts)
|
||||
if err := pw.start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pw, nil
|
||||
}
|
||||
|
||||
type pageWrapper struct {
|
||||
opts enginewebview.OpenOptions
|
||||
|
||||
callbackName string
|
||||
readyID string
|
||||
|
||||
view webview.WebView
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]chan evalResult
|
||||
url string
|
||||
closed bool
|
||||
|
||||
ready chan error
|
||||
done chan struct{}
|
||||
loads chan string
|
||||
}
|
||||
|
||||
type evalMessage struct {
|
||||
ID string `json:"id"`
|
||||
Ready bool `json:"ready,omitempty"`
|
||||
Load bool `json:"load,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
OK bool `json:"ok,omitempty"`
|
||||
Value any `json:"value,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type evalResult struct {
|
||||
value any
|
||||
err error
|
||||
}
|
||||
|
||||
type nativeCookieJSON struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Domain string `json:"domain"`
|
||||
Path string `json:"path"`
|
||||
Expires string `json:"expires"`
|
||||
Secure bool `json:"secure"`
|
||||
HTTPOnly bool `json:"httpOnly"`
|
||||
}
|
||||
|
||||
var providerSeq atomic.Uint64
|
||||
|
||||
func newPageWrapper(opts enginewebview.OpenOptions) *pageWrapper {
|
||||
seq := providerSeq.Add(1)
|
||||
return &pageWrapper{
|
||||
opts: opts,
|
||||
callbackName: fmt.Sprintf("__gopeedWebViewCallback_%d", seq),
|
||||
readyID: fmt.Sprintf("__ready__%d", seq),
|
||||
pending: make(map[string]chan evalResult),
|
||||
ready: make(chan error, 1),
|
||||
done: make(chan struct{}),
|
||||
loads: make(chan string, 8),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *pageWrapper) start() error {
|
||||
startedAt := time.Now()
|
||||
run := func() {
|
||||
runtimepkg.LockOSThread()
|
||||
defer runtimepkg.UnlockOSThread()
|
||||
|
||||
var w webview.WebView
|
||||
if p.opts.Headless {
|
||||
w = webview.NewHeadless(p.opts.Debug)
|
||||
} else {
|
||||
w = webview.New(p.opts.Debug)
|
||||
}
|
||||
p.view = w
|
||||
w.SetTitle(firstNonEmpty(p.opts.Title, "Gopeed WebView"))
|
||||
w.SetSize(defaultWindowDimension(p.opts.Width, 1280), defaultWindowDimension(p.opts.Height, 800), webview.HintNone)
|
||||
|
||||
if p.opts.UserAgent != "" {
|
||||
type userAgentSetter interface {
|
||||
SetUserAgent(userAgent string)
|
||||
}
|
||||
if setter, ok := w.(userAgentSetter); ok {
|
||||
setter.SetUserAgent(p.opts.UserAgent)
|
||||
}
|
||||
}
|
||||
w.Init(buildLoadNotifyScript(p.callbackName))
|
||||
|
||||
if err := w.Bind(p.callbackName, func(payload string) error {
|
||||
return p.handleCallback(payload)
|
||||
}); err != nil {
|
||||
p.ready <- err
|
||||
return
|
||||
}
|
||||
|
||||
applyWindowOptions(w, p.opts)
|
||||
w.SetHtml(buildBootstrapHTML(p.callbackName, p.readyID))
|
||||
w.Run()
|
||||
w.Destroy()
|
||||
close(p.done)
|
||||
}
|
||||
|
||||
if !postMainThreadTask(run) {
|
||||
go run()
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-p.ready:
|
||||
p.tracef("open %dms", time.Since(startedAt).Milliseconds())
|
||||
return err
|
||||
case <-time.After(10 * time.Second):
|
||||
p.tracef("open timeout %dms", time.Since(startedAt).Milliseconds())
|
||||
return fmt.Errorf("webview startup timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *pageWrapper) AddInitScript(script string) error {
|
||||
return p.dispatch(func(w webview.WebView) error {
|
||||
w.Init(script)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (p *pageWrapper) Goto(url string, opts enginewebview.GotoOptions) error {
|
||||
startedAt := time.Now()
|
||||
p.drainLoads()
|
||||
if err := p.dispatch(func(w webview.WebView) error {
|
||||
p.mu.Lock()
|
||||
p.url = url
|
||||
p.mu.Unlock()
|
||||
w.Navigate(url)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
timeout := 30 * time.Second
|
||||
if opts.TimeoutMS > 0 {
|
||||
timeout = time.Duration(opts.TimeoutMS) * time.Millisecond
|
||||
}
|
||||
waitUntil := opts.WaitUntil
|
||||
if waitUntil == "" {
|
||||
waitUntil = "load"
|
||||
}
|
||||
err := p.waitForNavigation(url, timeout, waitUntil)
|
||||
if err != nil {
|
||||
p.tracef("goto failed %dms url=%s err=%v", time.Since(startedAt).Milliseconds(), url, err)
|
||||
return err
|
||||
}
|
||||
p.tracef("goto %dms url=%s", time.Since(startedAt).Milliseconds(), url)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *pageWrapper) Execute(expression string, args ...any) (any, error) {
|
||||
startedAt := time.Now()
|
||||
if args == nil {
|
||||
args = []any{}
|
||||
}
|
||||
argsJSON, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqID := fmt.Sprintf("req-%d", providerSeq.Add(1))
|
||||
ch := make(chan evalResult, 1)
|
||||
|
||||
p.mu.Lock()
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
return nil, fmt.Errorf("webview page is closed")
|
||||
}
|
||||
p.pending[reqID] = ch
|
||||
p.mu.Unlock()
|
||||
|
||||
script := buildExecuteScript(p.callbackName, reqID, expression, string(argsJSON))
|
||||
if err := p.dispatch(func(w webview.WebView) error {
|
||||
w.Eval(script)
|
||||
return nil
|
||||
}); err != nil {
|
||||
p.mu.Lock()
|
||||
delete(p.pending, reqID)
|
||||
p.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := <-ch
|
||||
if result.err != nil {
|
||||
p.tracef("execute failed %dms err=%v", time.Since(startedAt).Milliseconds(), result.err)
|
||||
return result.value, result.err
|
||||
}
|
||||
p.tracef("execute %dms", time.Since(startedAt).Milliseconds())
|
||||
return result.value, nil
|
||||
}
|
||||
|
||||
func (p *pageWrapper) GetCookies() ([]enginewebview.Cookie, error) {
|
||||
cookies, err := p.nativeCookies()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]enginewebview.Cookie, 0, len(cookies))
|
||||
for _, cookie := range cookies {
|
||||
item := enginewebview.Cookie{
|
||||
Name: cookie.Name,
|
||||
Value: cookie.Value,
|
||||
Domain: cookie.Domain,
|
||||
Path: cookie.Path,
|
||||
Secure: cookie.Secure,
|
||||
HTTPOnly: cookie.HTTPOnly,
|
||||
}
|
||||
if cookie.Expires != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, cookie.Expires); err == nil {
|
||||
item.Expires = parsed
|
||||
}
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *pageWrapper) SetCookie(cookie enginewebview.Cookie) error {
|
||||
type cookieSetter interface {
|
||||
SetCookie(cookie webview.Cookie) error
|
||||
}
|
||||
return p.dispatch(func(w webview.WebView) error {
|
||||
setter, ok := w.(cookieSetter)
|
||||
if !ok {
|
||||
return fmt.Errorf("native cookie access is unavailable")
|
||||
}
|
||||
return setter.SetCookie(webview.Cookie{
|
||||
Name: cookie.Name,
|
||||
Value: cookie.Value,
|
||||
Domain: cookie.Domain,
|
||||
Path: cookie.Path,
|
||||
Expires: cookie.Expires,
|
||||
Secure: cookie.Secure,
|
||||
HTTPOnly: cookie.HTTPOnly,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (p *pageWrapper) DeleteCookie(cookie enginewebview.Cookie) error {
|
||||
type cookieDeleter interface {
|
||||
DeleteCookie(name string, domain string, path string) error
|
||||
}
|
||||
return p.dispatch(func(w webview.WebView) error {
|
||||
deleter, ok := w.(cookieDeleter)
|
||||
if !ok {
|
||||
return fmt.Errorf("native cookie access is unavailable")
|
||||
}
|
||||
return deleter.DeleteCookie(cookie.Name, cookie.Domain, cookie.Path)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *pageWrapper) ClearCookies() error {
|
||||
type cookieClearer interface {
|
||||
ClearCookies() error
|
||||
}
|
||||
return p.dispatch(func(w webview.WebView) error {
|
||||
clearer, ok := w.(cookieClearer)
|
||||
if !ok {
|
||||
return fmt.Errorf("native cookie access is unavailable")
|
||||
}
|
||||
return clearer.ClearCookies()
|
||||
})
|
||||
}
|
||||
|
||||
func (p *pageWrapper) Close() error {
|
||||
p.mu.Lock()
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
p.closed = true
|
||||
w := p.view
|
||||
wait := p.done
|
||||
p.mu.Unlock()
|
||||
|
||||
if w != nil {
|
||||
// PostQuitMessage (used by the Windows WebView2 terminate_impl)
|
||||
// only takes effect on the thread that owns the message loop.
|
||||
// Dispatch the termination to the webview's UI thread so that
|
||||
// the quit message lands on the correct queue.
|
||||
terminateDone := make(chan struct{})
|
||||
w.Dispatch(func() {
|
||||
w.Terminate()
|
||||
close(terminateDone)
|
||||
})
|
||||
<-terminateDone
|
||||
}
|
||||
<-wait
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *pageWrapper) tracef(format string, args ...any) {
|
||||
if !strings.Contains(p.opts.Title, "youtube-sabr") {
|
||||
return
|
||||
}
|
||||
fmt.Printf("goprovider "+format+"\n", args...)
|
||||
}
|
||||
|
||||
func (p *pageWrapper) waitForNavigation(targetURL string, timeout time.Duration, waitUntil string) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.loads:
|
||||
if waitUntil == "load" {
|
||||
return nil
|
||||
}
|
||||
case <-ticker.C:
|
||||
state, err := p.navigationState()
|
||||
if err == nil && state.ready(targetURL, waitUntil) {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("webview navigation timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type navigationState struct {
|
||||
URL string `json:"url"`
|
||||
ReadyState string `json:"readyState"`
|
||||
}
|
||||
|
||||
func (s navigationState) ready(targetURL string, waitUntil string) bool {
|
||||
if s.URL == "" || s.URL == "about:blank" {
|
||||
return false
|
||||
}
|
||||
switch waitUntil {
|
||||
case "domcontentloaded":
|
||||
if s.ReadyState == "loading" || s.ReadyState == "" {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if s.ReadyState != "complete" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if targetURL == "" {
|
||||
return true
|
||||
}
|
||||
if s.URL == targetURL {
|
||||
return true
|
||||
}
|
||||
target, err := parseURL(targetURL)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
current, err := parseURL(s.URL)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if current.Scheme == target.Scheme && current.Host == target.Host && current.Path == target.Path {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseURL(raw string) (*url.URL, error) {
|
||||
return url.Parse(raw)
|
||||
}
|
||||
|
||||
func (p *pageWrapper) navigationState() (navigationState, error) {
|
||||
value, err := p.Execute(`() => ({
|
||||
url: String(location.href || ""),
|
||||
readyState: document.readyState || "",
|
||||
})`)
|
||||
if err != nil {
|
||||
return navigationState{}, err
|
||||
}
|
||||
stateMap, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return navigationState{}, fmt.Errorf("invalid navigation state")
|
||||
}
|
||||
state := navigationState{}
|
||||
if urlValue, ok := stateMap["url"].(string); ok {
|
||||
state.URL = urlValue
|
||||
}
|
||||
if readyValue, ok := stateMap["readyState"].(string); ok {
|
||||
state.ReadyState = readyValue
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (p *pageWrapper) dispatch(fn func(w webview.WebView) error) error {
|
||||
p.mu.Lock()
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
return fmt.Errorf("webview page is closed")
|
||||
}
|
||||
w := p.view
|
||||
p.mu.Unlock()
|
||||
if w == nil {
|
||||
return fmt.Errorf("webview is not initialized")
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
w.Dispatch(func() {
|
||||
done <- fn(w)
|
||||
})
|
||||
return <-done
|
||||
}
|
||||
|
||||
func (p *pageWrapper) nativeCookies() ([]nativeCookieJSON, error) {
|
||||
type cookieGetter interface {
|
||||
GetCookies(url string) ([]webview.Cookie, error)
|
||||
}
|
||||
|
||||
var cookies []webview.Cookie
|
||||
err := p.dispatch(func(w webview.WebView) error {
|
||||
getter, ok := w.(cookieGetter)
|
||||
if !ok {
|
||||
return fmt.Errorf("native cookie access is unavailable")
|
||||
}
|
||||
p.mu.Lock()
|
||||
url := p.url
|
||||
p.mu.Unlock()
|
||||
if url == "" {
|
||||
url = "about:blank"
|
||||
}
|
||||
var err error
|
||||
cookies, err = getter.GetCookies(url)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]nativeCookieJSON, 0, len(cookies))
|
||||
for _, cookie := range cookies {
|
||||
item := nativeCookieJSON{
|
||||
Name: cookie.Name,
|
||||
Value: cookie.Value,
|
||||
Domain: cookie.Domain,
|
||||
Path: cookie.Path,
|
||||
Secure: cookie.Secure,
|
||||
HTTPOnly: cookie.HTTPOnly,
|
||||
}
|
||||
if !cookie.Expires.IsZero() {
|
||||
item.Expires = cookie.Expires.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *pageWrapper) handleCallback(payload string) error {
|
||||
var msg evalMessage
|
||||
if err := json.Unmarshal([]byte(payload), &msg); err != nil {
|
||||
return err
|
||||
}
|
||||
if msg.Ready && msg.ID == p.readyID {
|
||||
select {
|
||||
case p.ready <- nil:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if msg.Load {
|
||||
select {
|
||||
case p.loads <- msg.URL:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
ch := p.pending[msg.ID]
|
||||
delete(p.pending, msg.ID)
|
||||
p.mu.Unlock()
|
||||
if ch == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !msg.OK {
|
||||
ch <- evalResult{err: errors.New(strings.TrimSpace(msg.Error))}
|
||||
return nil
|
||||
}
|
||||
ch <- evalResult{value: msg.Value}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *pageWrapper) drainLoads() {
|
||||
for {
|
||||
select {
|
||||
case <-p.loads:
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildBootstrapHTML(callbackName string, readyID string) string {
|
||||
return fmt.Sprintf(`<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body>
|
||||
<script>
|
||||
window.addEventListener("load", () => {
|
||||
const callback = globalThis[%q];
|
||||
if (typeof callback === "function") {
|
||||
callback(JSON.stringify({ id: %q, ready: true }));
|
||||
}
|
||||
}, { once: true });
|
||||
</script>
|
||||
</body>
|
||||
</html>`, callbackName, readyID)
|
||||
}
|
||||
|
||||
func buildExecuteScript(callbackName string, reqID string, expression string, argsJSON string) string {
|
||||
callbackJSON, _ := json.Marshal(callbackName)
|
||||
reqJSON, _ := json.Marshal(reqID)
|
||||
exprJSON, _ := json.Marshal(expression)
|
||||
argsLiteral := argsJSON
|
||||
if argsLiteral == "" || argsLiteral == "null" {
|
||||
argsLiteral = "[]"
|
||||
}
|
||||
return fmt.Sprintf(`(() => {
|
||||
const __cb = globalThis[%s];
|
||||
const __id = %s;
|
||||
const __expression = %s;
|
||||
const __args = %s;
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
const __target = (0, eval)(__expression);
|
||||
const __value = await (typeof __target === "function" ? __target(...__args) : __target);
|
||||
return { id: __id, ok: true, value: __value ?? null };
|
||||
})
|
||||
.catch((error) => ({
|
||||
id: __id,
|
||||
ok: false,
|
||||
error: [
|
||||
error && error.name ? error.name : "Error",
|
||||
error && error.message ? error.message : String(error),
|
||||
error && error.stack ? error.stack : "",
|
||||
].filter(Boolean).join(": ").replace(": @", "\n@"),
|
||||
}))
|
||||
.then((message) => __cb(JSON.stringify(message)));
|
||||
})();`, string(callbackJSON), string(reqJSON), string(exprJSON), argsLiteral)
|
||||
}
|
||||
|
||||
func buildLoadNotifyScript(callbackName string) string {
|
||||
callbackJSON, _ := json.Marshal(callbackName)
|
||||
return fmt.Sprintf(`(() => {
|
||||
window.addEventListener("load", () => {
|
||||
const __cb = globalThis[%s];
|
||||
if (typeof __cb !== "function") {
|
||||
return;
|
||||
}
|
||||
__cb(JSON.stringify({
|
||||
load: true,
|
||||
url: String(location.href || ""),
|
||||
}));
|
||||
}, { once: true });
|
||||
})();`, string(callbackJSON))
|
||||
}
|
||||
|
||||
func defaultWindowDimension(value int, fallback int) int {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build cgo && webview && !darwin
|
||||
|
||||
package goprovider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/webview/integrationtest"
|
||||
)
|
||||
|
||||
func TestProviderContract(t *testing.T) {
|
||||
integrationtest.RunProviderContract(t, New(), integrationtest.ContractOptions{
|
||||
CookieDomainMode: integrationtest.CookieDomainModeRequired,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !cgo
|
||||
|
||||
package goprovider
|
||||
|
||||
import enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
|
||||
func New() enginewebview.Provider {
|
||||
return enginewebview.NewUnavailableProvider()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build cgo && darwin
|
||||
|
||||
package goprovider
|
||||
|
||||
/*
|
||||
#cgo darwin CFLAGS: -x objective-c
|
||||
#cgo darwin LDFLAGS: -framework Cocoa
|
||||
|
||||
#include <Cocoa/Cocoa.h>
|
||||
|
||||
static void gopeedApplyHeadlessWindowOptions(void *windowPtr, int headless) {
|
||||
if (windowPtr == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSWindow *window = (__bridge NSWindow *)windowPtr;
|
||||
if (headless) {
|
||||
[window orderOut:nil];
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
webview "github.com/GopeedLab/webview_go"
|
||||
)
|
||||
|
||||
func applyWindowOptions(w webview.WebView, opts enginewebview.OpenOptions) {
|
||||
C.gopeedApplyHeadlessWindowOptions(w.Window(), C.int(boolToInt(opts.Headless)))
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build cgo && !darwin
|
||||
|
||||
package goprovider
|
||||
|
||||
import (
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
webview "github.com/GopeedLab/webview_go"
|
||||
)
|
||||
|
||||
func applyWindowOptions(_ webview.WebView, _ enginewebview.OpenOptions) {}
|
||||
@@ -0,0 +1,378 @@
|
||||
package integrationtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
const TestUserAgent = "GopeedWebViewIntegration/1.0"
|
||||
|
||||
type ContractOptions struct {
|
||||
AvailabilityTimeout time.Duration
|
||||
CookieDomainMode CookieDomainMode
|
||||
CookieTestURL string
|
||||
}
|
||||
|
||||
type CookieDomainMode int
|
||||
|
||||
const (
|
||||
CookieDomainModeRequired CookieDomainMode = iota
|
||||
CookieDomainModeOmit
|
||||
)
|
||||
|
||||
func RunProviderContract(t *testing.T, provider enginewebview.Provider, opts ContractOptions) {
|
||||
t.Helper()
|
||||
|
||||
timeout := opts.AvailabilityTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
waitForProviderAvailable(t, provider, timeout)
|
||||
|
||||
page, server, testURL := openTestPage(t, provider, enginewebview.OpenOptions{
|
||||
Headless: true,
|
||||
Title: "Gopeed WebView Integration",
|
||||
Width: 640,
|
||||
Height: 480,
|
||||
UserAgent: TestUserAgent,
|
||||
})
|
||||
|
||||
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
|
||||
t.Fatalf("goto failed: %v", err)
|
||||
}
|
||||
|
||||
ua, err := page.Execute(`() => navigator.userAgent`)
|
||||
if err != nil {
|
||||
t.Fatalf("navigator.userAgent execute failed: %v", err)
|
||||
}
|
||||
if ua != TestUserAgent {
|
||||
t.Fatalf("unexpected user agent: got %v want %q", ua, TestUserAgent)
|
||||
}
|
||||
|
||||
if err := page.Focus("#name"); err != nil {
|
||||
t.Fatalf("focus failed: %v", err)
|
||||
}
|
||||
activeElement, err := page.Execute(`() => document.activeElement && document.activeElement.id`)
|
||||
if err != nil {
|
||||
t.Fatalf("activeElement execute failed: %v", err)
|
||||
}
|
||||
if activeElement != "name" {
|
||||
t.Fatalf("expected active element to be #name, got %v", activeElement)
|
||||
}
|
||||
|
||||
if err := page.Type("#name", "Gopeed", map[string]any{"delay": 5}); err != nil {
|
||||
t.Fatalf("type failed: %v", err)
|
||||
}
|
||||
typeState, err := page.Execute(`() => ({
|
||||
value: document.querySelector('#name').value,
|
||||
mirror: document.querySelector('#name-value').textContent,
|
||||
inputCount: window.__inputEvents.length
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("type state execute failed: %v", err)
|
||||
}
|
||||
stateMap, ok := typeState.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected type state payload: %T", typeState)
|
||||
}
|
||||
if stateMap["value"] != "Gopeed" || stateMap["mirror"] != "Gopeed" {
|
||||
t.Fatalf("unexpected type state: %#v", stateMap)
|
||||
}
|
||||
if stateMap["inputCount"] != float64(len("Gopeed")) {
|
||||
t.Fatalf("unexpected input event count: %#v", stateMap["inputCount"])
|
||||
}
|
||||
|
||||
if err := page.Click("#clicker"); err != nil {
|
||||
t.Fatalf("click failed: %v", err)
|
||||
}
|
||||
clickCount, err := page.Execute(`() => document.querySelector('#click-count').textContent`)
|
||||
if err != nil {
|
||||
t.Fatalf("click count execute failed: %v", err)
|
||||
}
|
||||
if clickCount != "1" {
|
||||
t.Fatalf("expected click count to be 1, got %v", clickCount)
|
||||
}
|
||||
|
||||
found, err := page.WaitForSelector("#delayed", map[string]any{
|
||||
"timeoutMs": 5000,
|
||||
"pollIntervalMs": 25,
|
||||
"visible": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForSelector failed: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected delayed selector to appear")
|
||||
}
|
||||
|
||||
value, err := page.WaitForFunction(`() => window.__readyValue`, map[string]any{
|
||||
"timeoutMs": 5000,
|
||||
"pollIntervalMs": 25,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForFunction failed: %v", err)
|
||||
}
|
||||
readyValue, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected waitForFunction value: %T", value)
|
||||
}
|
||||
if readyValue["status"] != "ready" {
|
||||
t.Fatalf("unexpected waitForFunction payload: %#v", readyValue)
|
||||
}
|
||||
|
||||
found, err = page.WaitForSelector("#missing", map[string]any{
|
||||
"timeoutMs": 200,
|
||||
"pollIntervalMs": 25,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForSelector timeout case failed: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("expected missing selector wait to time out")
|
||||
}
|
||||
|
||||
cookieTestURL := opts.CookieTestURL
|
||||
if cookieTestURL == "" {
|
||||
cookieTestURL = testURL
|
||||
}
|
||||
runCookieLifecycleAssertions(t, page, server, cookieTestURL, opts.CookieDomainMode)
|
||||
}
|
||||
|
||||
func waitForProviderAvailable(t *testing.T, provider enginewebview.Provider, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if provider != nil && provider.IsAvailable() {
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("provider did not become available within %s", timeout)
|
||||
}
|
||||
|
||||
func openTestPage(t *testing.T, provider enginewebview.Provider, opts enginewebview.OpenOptions) (*enginewebview.PageHandle, *httptest.Server, string) {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
switch r.URL.Path {
|
||||
case "/next":
|
||||
fmt.Fprint(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Next Page</title></head>
|
||||
<body>
|
||||
<h1 id="next-page">Navigation complete</h1>
|
||||
</body>
|
||||
</html>`)
|
||||
default:
|
||||
fmt.Fprint(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Test Page</title></head>
|
||||
<body>
|
||||
<input id="name" type="text" />
|
||||
<div id="name-value"></div>
|
||||
<button id="clicker" type="button">Click me</button>
|
||||
<div id="click-count">0</div>
|
||||
<button id="nav-btn" type="button">Navigate</button>
|
||||
<script>
|
||||
window.__inputEvents = [];
|
||||
window.__readyValue = null;
|
||||
const nameInput = document.getElementById('name');
|
||||
const nameValue = document.getElementById('name-value');
|
||||
nameInput.addEventListener('input', () => {
|
||||
nameValue.textContent = nameInput.value;
|
||||
window.__inputEvents.push(nameInput.value);
|
||||
});
|
||||
document.getElementById('clicker').addEventListener('click', () => {
|
||||
const counter = document.getElementById('click-count');
|
||||
counter.textContent = String(Number(counter.textContent || '0') + 1);
|
||||
});
|
||||
document.getElementById('nav-btn').addEventListener('click', () => {
|
||||
setTimeout(() => {
|
||||
window.location.href = '/next';
|
||||
}, 50);
|
||||
});
|
||||
setTimeout(() => {
|
||||
const marker = document.createElement('div');
|
||||
marker.id = 'delayed';
|
||||
marker.textContent = 'Loaded later';
|
||||
document.body.appendChild(marker);
|
||||
}, 150);
|
||||
setTimeout(() => {
|
||||
window.__readyValue = { status: 'ready', source: 'timer' };
|
||||
}, 200);
|
||||
</script>
|
||||
</body>
|
||||
</html>`)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
runtime := enginewebview.NewRuntime(provider, true)
|
||||
page, err := runtime.Open(map[string]any{
|
||||
"headless": opts.Headless,
|
||||
"debug": opts.Debug,
|
||||
"title": opts.Title,
|
||||
"width": opts.Width,
|
||||
"height": opts.Height,
|
||||
"userAgent": opts.UserAgent,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open webview: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = runtime.Close()
|
||||
})
|
||||
|
||||
return page, server, server.URL
|
||||
}
|
||||
|
||||
func runCookieLifecycleAssertions(t *testing.T, page *enginewebview.PageHandle, _ *httptest.Server, testURL string, domainMode CookieDomainMode) {
|
||||
t.Helper()
|
||||
|
||||
parsedURL, err := url.Parse(testURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse cookie test url failed: %v", err)
|
||||
}
|
||||
cookieDomain := parsedURL.Hostname()
|
||||
|
||||
if err := page.ClearCookies(); err != nil {
|
||||
t.Fatalf("clearCookies before cookie assertions failed: %v", err)
|
||||
}
|
||||
|
||||
visibleCookie := map[string]any{
|
||||
"name": "visible",
|
||||
"value": "1",
|
||||
"path": "/",
|
||||
}
|
||||
secretCookie := map[string]any{
|
||||
"name": "secret",
|
||||
"value": "2",
|
||||
"path": "/",
|
||||
"httpOnly": true,
|
||||
"expires": time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
deleteCookie := map[string]any{
|
||||
"name": "visible",
|
||||
"path": "/",
|
||||
}
|
||||
if domainMode == CookieDomainModeRequired {
|
||||
visibleCookie["domain"] = cookieDomain
|
||||
secretCookie["domain"] = cookieDomain
|
||||
deleteCookie["domain"] = cookieDomain
|
||||
}
|
||||
|
||||
if err := page.SetCookie(visibleCookie); err != nil {
|
||||
t.Fatalf("set visible cookie failed: %v", err)
|
||||
}
|
||||
if err := page.SetCookie(secretCookie); err != nil {
|
||||
t.Fatalf("set secret cookie failed: %v", err)
|
||||
}
|
||||
|
||||
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
|
||||
t.Fatalf("goto after setCookie failed: %v", err)
|
||||
}
|
||||
|
||||
documentCookie, err := page.Execute(`() => document.cookie`)
|
||||
if err != nil {
|
||||
t.Fatalf("document.cookie execute failed: %v", err)
|
||||
}
|
||||
documentCookieText, ok := documentCookie.(string)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected document.cookie payload: %T", documentCookie)
|
||||
}
|
||||
if !strings.Contains(documentCookieText, "visible=1") {
|
||||
t.Fatalf("expected visible cookie in document.cookie, got %q", documentCookieText)
|
||||
}
|
||||
if strings.Contains(documentCookieText, "secret=2") {
|
||||
t.Fatalf("expected httpOnly cookie to be hidden from document.cookie, got %q", documentCookieText)
|
||||
}
|
||||
|
||||
cookies, err := page.GetCookies()
|
||||
if err != nil {
|
||||
t.Fatalf("getCookies failed: %v", err)
|
||||
}
|
||||
if len(cookies) < 2 {
|
||||
t.Fatalf("expected at least 2 cookies, got %#v", cookies)
|
||||
}
|
||||
if !hasCookie(cookies, "visible", "1") {
|
||||
t.Fatalf("expected visible cookie in native cookie store, got %#v", cookies)
|
||||
}
|
||||
if !hasCookie(cookies, "secret", "2") {
|
||||
t.Fatalf("expected secret cookie in native cookie store, got %#v", cookies)
|
||||
}
|
||||
|
||||
if err := page.DeleteCookie(deleteCookie); err != nil {
|
||||
t.Fatalf("deleteCookie failed: %v", err)
|
||||
}
|
||||
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
|
||||
t.Fatalf("goto after deleteCookie failed: %v", err)
|
||||
}
|
||||
|
||||
documentCookie, err = page.Execute(`() => document.cookie`)
|
||||
if err != nil {
|
||||
t.Fatalf("document.cookie execute after delete failed: %v", err)
|
||||
}
|
||||
documentCookieText, ok = documentCookie.(string)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected document.cookie payload after delete: %T", documentCookie)
|
||||
}
|
||||
if strings.Contains(documentCookieText, "visible=1") {
|
||||
t.Fatalf("expected visible cookie to be deleted, got %q", documentCookieText)
|
||||
}
|
||||
if strings.Contains(documentCookieText, "secret=2") {
|
||||
t.Fatalf("expected httpOnly cookie to remain hidden after delete, got %q", documentCookieText)
|
||||
}
|
||||
|
||||
cookies, err = page.GetCookies()
|
||||
if err != nil {
|
||||
t.Fatalf("getCookies after delete failed: %v", err)
|
||||
}
|
||||
if hasCookie(cookies, "visible", "1") {
|
||||
t.Fatalf("expected visible cookie to be removed from native store, got %#v", cookies)
|
||||
}
|
||||
if !hasCookie(cookies, "secret", "2") {
|
||||
t.Fatalf("expected secret cookie to remain in native store after delete, got %#v", cookies)
|
||||
}
|
||||
|
||||
if err := page.ClearCookies(); err != nil {
|
||||
t.Fatalf("clearCookies failed: %v", err)
|
||||
}
|
||||
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
|
||||
t.Fatalf("goto after clearCookies failed: %v", err)
|
||||
}
|
||||
documentCookie, err = page.Execute(`() => document.cookie`)
|
||||
if err != nil {
|
||||
t.Fatalf("document.cookie execute after clear failed: %v", err)
|
||||
}
|
||||
documentCookieText, ok = documentCookie.(string)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected document.cookie payload after clear: %T", documentCookie)
|
||||
}
|
||||
if documentCookieText != "" {
|
||||
t.Fatalf("expected document.cookie to be empty after clear, got %q", documentCookieText)
|
||||
}
|
||||
cookies, err = page.GetCookies()
|
||||
if err != nil {
|
||||
t.Fatalf("getCookies after clear failed: %v", err)
|
||||
}
|
||||
if len(cookies) != 0 {
|
||||
t.Fatalf("expected cookie store to be empty after clear, got %#v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
func hasCookie(cookies []enginewebview.Cookie, name string, value string) bool {
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == name && cookie.Value == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
# rpcprovider
|
||||
|
||||
`rpcprovider` is the RPC-backed implementation of Gopeed's `webview.Provider` interface.
|
||||
|
||||
It is intended for environments where WebView capability is owned by another process, most notably mobile hosts. This package does not implement a WebView itself. Instead, it translates the `pkg/download/engine/webview` page API into single-request `POST + JSON` calls sent to a local host-side RPC service.
|
||||
|
||||
## When to use it
|
||||
|
||||
Use `rpcprovider` for entrypoints such as `bind/mobile`, where the Go process cannot directly own a native WebView.
|
||||
|
||||
Do not use it for desktop or `cmd/web` local WebView integration. Those entrypoints should use `internal/webview/goprovider` instead.
|
||||
|
||||
## Configuration
|
||||
|
||||
The provider is configured through `pkg/download/engine/webview.RPCConfig`:
|
||||
|
||||
```go
|
||||
type RPCConfig struct {
|
||||
Network string `json:"network"`
|
||||
Address string `json:"address"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `Network` currently supports `tcp` and `unix`.
|
||||
- `Address` examples:
|
||||
- `127.0.0.1:38765`
|
||||
- `/path/to/webview.sock`
|
||||
- `Token` is optional. When set, the client sends `Authorization: Bearer <token>`.
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
provider := rpcprovider.New(webview.RPCConfig{
|
||||
Network: "tcp",
|
||||
Address: "127.0.0.1:38765",
|
||||
Token: "secret-token",
|
||||
})
|
||||
```
|
||||
|
||||
## Transport
|
||||
|
||||
- Endpoint: `POST /webview`
|
||||
- Content type: `application/json`
|
||||
- One request, one response
|
||||
- No `id` field yet
|
||||
- The envelope is intentionally close to JSON-RPC so it can evolve later without renaming the methods or reshaping the payloads
|
||||
|
||||
Request example:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.execute",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"expression": "document.title",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Successful response:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"title": "Example"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
Failure response:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": null,
|
||||
"error": {
|
||||
"code": "PAGE_NOT_FOUND",
|
||||
"message": "page not found"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Supported methods
|
||||
|
||||
- `webview.isAvailable`
|
||||
- `page.open`
|
||||
- `page.addInitScript`
|
||||
- `page.goto`
|
||||
- `page.execute`
|
||||
- `page.getCookies`
|
||||
- `page.setCookie`
|
||||
- `page.deleteCookie`
|
||||
- `page.clearCookies`
|
||||
- `page.close`
|
||||
|
||||
The canonical wire models for these methods are defined in:
|
||||
|
||||
- [pkg/download/engine/webview/rpc.go](../../../pkg/download/engine/webview/rpc.go)
|
||||
- [pkg/download/engine/webview/runtime.go](../../../pkg/download/engine/webview/runtime.go)
|
||||
|
||||
The README is only a guide. The Go source above is the authoritative protocol reference.
|
||||
|
||||
## Method reference
|
||||
|
||||
Each method below uses the common envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "<method-name>",
|
||||
"params": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
and returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": ...,
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": null,
|
||||
"error": {
|
||||
"code": "<ERROR_CODE>",
|
||||
"message": "<message>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `webview.isAvailable`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "webview.isAvailable",
|
||||
"params": {}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"available": true
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.open`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.open",
|
||||
"params": {
|
||||
"headless": true,
|
||||
"debug": false,
|
||||
"title": "Gopeed WebView",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"userAgent": "Mozilla/5.0 ..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"pageId": "page-1"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.addInitScript`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.addInitScript",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"script": "window.__READY__ = true;"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.goto`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.goto",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"url": "https://example.com",
|
||||
"timeoutMs": 15000,
|
||||
"waitUntil": "domcontentloaded"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
`waitUntil` currently supports `load` and `domcontentloaded`. The default is `load`.
|
||||
|
||||
### `page.execute`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.execute",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"expression": "document.title",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": "Example Domain",
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.getCookies`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.getCookies",
|
||||
"params": {
|
||||
"pageId": "page-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": [
|
||||
{
|
||||
"name": "session",
|
||||
"value": "abc",
|
||||
"domain": ".example.com",
|
||||
"path": "/",
|
||||
"expires": "2026-03-26T12:00:00Z",
|
||||
"secure": true,
|
||||
"httpOnly": true
|
||||
}
|
||||
],
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.setCookie`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.setCookie",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"cookie": {
|
||||
"name": "session",
|
||||
"value": "abc",
|
||||
"domain": ".example.com",
|
||||
"path": "/",
|
||||
"expires": "2026-03-26T12:00:00Z",
|
||||
"secure": true,
|
||||
"httpOnly": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.deleteCookie`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.deleteCookie",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"cookie": {
|
||||
"name": "session",
|
||||
"domain": ".example.com",
|
||||
"path": "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.clearCookies`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.clearCookies",
|
||||
"params": {
|
||||
"pageId": "page-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.close`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.close",
|
||||
"params": {
|
||||
"pageId": "page-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
## Method semantics
|
||||
|
||||
- `page.open`
|
||||
- Creates a page session
|
||||
- Returns `pageId`
|
||||
- `page.addInitScript`
|
||||
- Registers a page initialization script
|
||||
- `page.goto`
|
||||
- Goes to the target URL
|
||||
- The server should return only after navigation completes or fails
|
||||
- `page.execute`
|
||||
- Executes an expression or serialized function body
|
||||
- The returned value must be JSON-serializable
|
||||
- `page.getCookies`
|
||||
- Returns cookies from the underlying native WebView cookie store
|
||||
- `page.setCookie`
|
||||
- Inserts or updates a cookie
|
||||
- `page.deleteCookie`
|
||||
- Deletes a cookie using `name/domain/path`
|
||||
- `page.clearCookies`
|
||||
- Clears the current cookie store
|
||||
- `page.close`
|
||||
- Closes the page and releases related resources
|
||||
|
||||
## Cookie model
|
||||
|
||||
Cookies use `runtime/webview.Cookie`.
|
||||
|
||||
Example payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "session",
|
||||
"value": "abc",
|
||||
"domain": ".example.com",
|
||||
"path": "/",
|
||||
"expires": "2026-03-26T12:00:00Z",
|
||||
"secure": true,
|
||||
"httpOnly": true
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `expires` uses RFC3339 / RFC3339Nano string form.
|
||||
- If the host platform supports a full native cookie store, it should return fields such as `httpOnly` and `secure`.
|
||||
- `setCookie -> goto` is a valid pattern for preloading authenticated session state.
|
||||
|
||||
## Error codes
|
||||
|
||||
The current error code set is defined in `pkg/download/engine/webview/rpc.go`:
|
||||
|
||||
- `INVALID_REQUEST`
|
||||
- `UNKNOWN_METHOD`
|
||||
- `UNAVAILABLE`
|
||||
- `BROWSER_NOT_FOUND`
|
||||
- `PAGE_NOT_FOUND`
|
||||
- `NAVIGATION_FAILED`
|
||||
- `EVALUATION_FAILED`
|
||||
- `TIMEOUT`
|
||||
- `INTERNAL_ERROR`
|
||||
|
||||
`BROWSER_NOT_FOUND` is a historical leftover from an earlier shape of the protocol. The current protocol no longer has a separate browser model, so this can be cleaned up later if the protocol is tightened further.
|
||||
|
||||
## Current protocol boundary
|
||||
|
||||
This protocol is intentionally limited:
|
||||
|
||||
- Single-direction request/response only
|
||||
- Not REST-style resource routing
|
||||
- No bidirectional events
|
||||
- No streaming event subscriptions
|
||||
- No host-initiated push channel
|
||||
|
||||
If the protocol needs to evolve toward full JSON-RPC later, the intended migration path is to keep the existing `method/params/result/error` structure and add:
|
||||
|
||||
- `id`
|
||||
- notifications
|
||||
- a bidirectional transport model
|
||||
|
||||
## Host implementation guidance
|
||||
|
||||
A host implementation should usually:
|
||||
|
||||
- Listen on a local `tcp` address or `unix socket`
|
||||
- Expose `POST /webview`
|
||||
- Validate the bearer token if configured
|
||||
- Maintain `pageId -> native webview/session`
|
||||
- Own page lifecycle, cleanup, and timeout handling
|
||||
|
||||
With that split, Gopeed stays a pure RPC client and does not need to know how the host's native WebView is implemented.
|
||||
@@ -0,0 +1,195 @@
|
||||
package rpcprovider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
func New(cfg enginewebview.RPCConfig) enginewebview.Provider {
|
||||
return &Provider{
|
||||
client: NewClient(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) IsAvailable() bool {
|
||||
if p == nil || p.client == nil || !p.client.Enabled() {
|
||||
return false
|
||||
}
|
||||
var result enginewebview.IsAvailableResult
|
||||
if err := p.client.Call(enginewebview.MethodIsAvailable, enginewebview.IsAvailableParams{}, &result); err != nil {
|
||||
return false
|
||||
}
|
||||
return result.Available
|
||||
}
|
||||
|
||||
func (p *Provider) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) {
|
||||
if p == nil || p.client == nil || !p.IsAvailable() {
|
||||
return nil, enginewebview.ErrUnavailable
|
||||
}
|
||||
var result enginewebview.PageOpenResult
|
||||
if err := p.client.Call(enginewebview.MethodPageOpen, enginewebview.NewPageOpenParams(opts), &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &page{
|
||||
client: p.client,
|
||||
id: result.PageID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type page struct {
|
||||
client *Client
|
||||
id string
|
||||
}
|
||||
|
||||
func (p *page) AddInitScript(script string) error {
|
||||
return p.client.Call(enginewebview.MethodPageAddInitScript, enginewebview.PageAddInitScriptParams{
|
||||
PageID: p.id,
|
||||
Script: script,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (p *page) Goto(url string, opts enginewebview.GotoOptions) error {
|
||||
return p.client.Call(enginewebview.MethodPageGoto, enginewebview.NewPageGotoParams(p.id, url, opts), nil)
|
||||
}
|
||||
|
||||
func (p *page) Execute(expression string, args ...any) (any, error) {
|
||||
var result any
|
||||
if err := p.client.Call(enginewebview.MethodPageExecute, enginewebview.PageExecuteParams{
|
||||
PageID: p.id,
|
||||
Expression: expression,
|
||||
Args: args,
|
||||
}, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *page) GetCookies() ([]enginewebview.Cookie, error) {
|
||||
var result []enginewebview.Cookie
|
||||
if err := p.client.Call(enginewebview.MethodPageGetCookies, enginewebview.PageGetCookiesParams{
|
||||
PageID: p.id,
|
||||
}, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
return []enginewebview.Cookie{}, nil
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *page) SetCookie(cookie enginewebview.Cookie) error {
|
||||
return p.client.Call(enginewebview.MethodPageSetCookie, enginewebview.PageSetCookieParams{
|
||||
PageID: p.id,
|
||||
Cookie: cookie,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (p *page) DeleteCookie(cookie enginewebview.Cookie) error {
|
||||
return p.client.Call(enginewebview.MethodPageDeleteCookie, enginewebview.PageDeleteCookieParams{
|
||||
PageID: p.id,
|
||||
Cookie: cookie,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (p *page) ClearCookies() error {
|
||||
return p.client.Call(enginewebview.MethodPageClearCookies, enginewebview.PageClearCookiesParams{
|
||||
PageID: p.id,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (p *page) Close() error {
|
||||
return p.client.Call(enginewebview.MethodPageClose, enginewebview.PageCloseParams{
|
||||
PageID: p.id,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
endpoint string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(cfg enginewebview.RPCConfig) *Client {
|
||||
client := &Client{token: cfg.Token}
|
||||
if !cfg.Enabled() {
|
||||
return client
|
||||
}
|
||||
switch strings.ToLower(cfg.Network) {
|
||||
case "unix":
|
||||
client.endpoint = "http://unix" + enginewebview.RPCEndpointPath
|
||||
client.http = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "unix", cfg.Address)
|
||||
},
|
||||
},
|
||||
}
|
||||
default:
|
||||
client.endpoint = "http://" + cfg.Address + enginewebview.RPCEndpointPath
|
||||
client.http = &http.Client{}
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func (c *Client) Enabled() bool {
|
||||
return c != nil && c.endpoint != "" && c.http != nil
|
||||
}
|
||||
|
||||
func (c *Client) Call(method string, params any, result any) error {
|
||||
if !c.Enabled() {
|
||||
return enginewebview.ErrUnavailable
|
||||
}
|
||||
payload, err := json.Marshal(enginewebview.RPCRequest{
|
||||
Method: method,
|
||||
Params: params,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("webview rpc status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body enginewebview.RPCResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
if body.Error != nil {
|
||||
return body.Error
|
||||
}
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if len(body.Result) == 0 || string(body.Result) == "null" {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(body.Result, result)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build webview && darwin
|
||||
|
||||
package rpcprovider
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/webview/integrationtest"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
var (
|
||||
webViewRPCNetwork = flag.String("webview-rpc-network", "unix", "WebView RPC network for integration tests")
|
||||
webViewRPCAddress = flag.String("webview-rpc-address", defaultWebViewRPCAddress(), "WebView RPC address for integration tests")
|
||||
webViewRPCToken = flag.String("webview-rpc-token", "", "WebView RPC token for integration tests")
|
||||
)
|
||||
|
||||
func TestProviderContract(t *testing.T) {
|
||||
provider := New(enginewebview.RPCConfig{
|
||||
Network: *webViewRPCNetwork,
|
||||
Address: *webViewRPCAddress,
|
||||
Token: *webViewRPCToken,
|
||||
})
|
||||
integrationtest.RunProviderContract(t, provider, integrationtest.ContractOptions{
|
||||
AvailabilityTimeout: 5 * time.Minute,
|
||||
CookieDomainMode: integrationtest.CookieDomainModeRequired,
|
||||
CookieTestURL: "https://example.com/",
|
||||
})
|
||||
}
|
||||
|
||||
func defaultWebViewRPCAddress() string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(homeDir, "Library", "Application Support", "com.gopeed.gopeed", "gopeed_webview.sock")
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package rpcprovider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
func TestProviderIsAvailableOverUnixSocket(t *testing.T) {
|
||||
socketPath := filepath.Join("/tmp", "gopeed-rpcwebview-"+t.Name()+".sock")
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
defer os.Remove(socketPath)
|
||||
|
||||
server := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req enginewebview.RPCRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Method != enginewebview.MethodIsAvailable {
|
||||
t.Fatalf("unexpected unix method: %s", req.Method)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"result": enginewebview.IsAvailableResult{Available: true},
|
||||
"error": nil,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}),
|
||||
}
|
||||
defer server.Close()
|
||||
go server.Serve(listener)
|
||||
|
||||
provider := New(enginewebview.RPCConfig{
|
||||
Network: "unix",
|
||||
Address: socketPath,
|
||||
})
|
||||
if !provider.IsAvailable() {
|
||||
t.Fatal("expected unix provider to be available")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package rpcprovider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
func TestProviderLifecycleOverHTTP(t *testing.T) {
|
||||
var calls []enginewebview.RPCRequest
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("unexpected method: %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != enginewebview.RPCEndpointPath {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer secret-token" {
|
||||
t.Fatalf("unexpected auth header: %q", got)
|
||||
}
|
||||
var req enginewebview.RPCRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls = append(calls, req)
|
||||
|
||||
var body any
|
||||
switch req.Method {
|
||||
case enginewebview.MethodIsAvailable:
|
||||
body = map[string]any{
|
||||
"result": enginewebview.IsAvailableResult{Available: true},
|
||||
"error": nil,
|
||||
}
|
||||
case enginewebview.MethodPageOpen:
|
||||
body = map[string]any{
|
||||
"result": enginewebview.PageOpenResult{PageID: "page-open-1"},
|
||||
"error": nil,
|
||||
}
|
||||
case enginewebview.MethodPageAddInitScript, enginewebview.MethodPageGoto:
|
||||
body = map[string]any{
|
||||
"result": enginewebview.EmptyResult{},
|
||||
"error": nil,
|
||||
}
|
||||
case enginewebview.MethodPageExecute:
|
||||
body = map[string]any{
|
||||
"result": map[string]any{"title": "Example"},
|
||||
"error": nil,
|
||||
}
|
||||
case enginewebview.MethodPageClose:
|
||||
body = map[string]any{
|
||||
"result": enginewebview.EmptyResult{},
|
||||
"error": nil,
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected method: %s", req.Method)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := enginewebview.RPCConfig{
|
||||
Network: "tcp",
|
||||
Address: server.Listener.Addr().String(),
|
||||
Token: "secret-token",
|
||||
}
|
||||
provider := New(cfg)
|
||||
if !provider.IsAvailable() {
|
||||
t.Fatal("expected provider to be available")
|
||||
}
|
||||
|
||||
opener, ok := provider.(enginewebview.Opener)
|
||||
if !ok {
|
||||
t.Fatal("expected provider to implement runtime webview opener")
|
||||
}
|
||||
opened, err := opener.Open(enginewebview.OpenOptions{
|
||||
Headless: true,
|
||||
Width: 1280,
|
||||
Height: 720,
|
||||
Title: "Gopeed",
|
||||
UserAgent: "UA",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := opened.AddInitScript("window.__TEST__ = true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := opened.Goto("https://example.com", enginewebview.GotoOptions{
|
||||
TimeoutMS: 3210,
|
||||
WaitUntil: "domcontentloaded",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := opened.Execute("document.title")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := value.(map[string]any)
|
||||
if !ok || got["title"] != "Example" {
|
||||
t.Fatalf("unexpected evaluate result: %#v", value)
|
||||
}
|
||||
if err := opened.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedMethods := []string{
|
||||
enginewebview.MethodIsAvailable,
|
||||
enginewebview.MethodIsAvailable,
|
||||
enginewebview.MethodPageOpen,
|
||||
enginewebview.MethodPageAddInitScript,
|
||||
enginewebview.MethodPageGoto,
|
||||
enginewebview.MethodPageExecute,
|
||||
enginewebview.MethodPageClose,
|
||||
}
|
||||
if len(calls) != len(expectedMethods) {
|
||||
t.Fatalf("unexpected call count: got %d want %d", len(calls), len(expectedMethods))
|
||||
}
|
||||
for i, method := range expectedMethods {
|
||||
if calls[i].Method != method {
|
||||
t.Fatalf("unexpected method at %d: got %s want %s", i, calls[i].Method, method)
|
||||
}
|
||||
}
|
||||
gotoParamsJSON, err := json.Marshal(calls[4].Params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var gotoParams enginewebview.PageGotoParams
|
||||
if err := json.Unmarshal(gotoParamsJSON, &gotoParams); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotoParams.WaitUntil != "domcontentloaded" {
|
||||
t.Fatalf("unexpected waitUntil: %q", gotoParams.WaitUntil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderLaunchUnavailableWithoutEndpoint(t *testing.T) {
|
||||
provider := New(enginewebview.RPCConfig{})
|
||||
if provider.IsAvailable() {
|
||||
t.Fatal("expected empty provider to be unavailable")
|
||||
}
|
||||
opener, ok := provider.(enginewebview.Opener)
|
||||
if !ok {
|
||||
t.Fatal("expected provider to implement runtime webview opener")
|
||||
}
|
||||
if _, err := opener.Open(enginewebview.OpenOptions{}); err == nil || err.Error() != enginewebview.ErrUnavailable.Error() {
|
||||
t.Fatalf("unexpected launch error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageSetCookieParamsOmitsZeroExpires(t *testing.T) {
|
||||
params := enginewebview.PageSetCookieParams{
|
||||
PageID: "page-1",
|
||||
Cookie: enginewebview.Cookie{
|
||||
Name: "visible",
|
||||
Value: "1",
|
||||
Domain: "example.com",
|
||||
Path: "/",
|
||||
},
|
||||
}
|
||||
payload, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(payload) == "" {
|
||||
t.Fatal("expected non-empty json payload")
|
||||
}
|
||||
if got := string(payload); strings.Contains(got, `"expires"`) {
|
||||
t.Fatalf("unexpected zero expires in payload: %s", got)
|
||||
}
|
||||
|
||||
params.Cookie.Expires = time.Now().UTC().Truncate(time.Second)
|
||||
payload, err = json.Marshal(params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(payload); !strings.Contains(got, `"expires"`) {
|
||||
t.Fatalf("expected expires in payload: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,11 @@ func NewDownloader(cfg *DownloaderConfig) *Downloader {
|
||||
}
|
||||
|
||||
func (d *Downloader) Setup() error {
|
||||
d.gblob = protogblob.NewRegistry(filepath.Join(d.cfg.StorageDir, protogblob.Scheme))
|
||||
gblobDir := filepath.Join(d.cfg.StorageDir, "gblob")
|
||||
if err := os.RemoveAll(gblobDir); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
d.gblob = protogblob.NewRegistry(gblobDir)
|
||||
|
||||
// setup storage
|
||||
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketProtocolState, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
|
||||
@@ -498,6 +502,11 @@ func (d *Downloader) CreateDirect(req *base.Request, opts *base.Options) (taskId
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(req.URL), protogblob.Scheme+":") {
|
||||
if err = fetcher.Resolve(req, initOpt); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return d.doCreate(fetcher, initOpt)
|
||||
}
|
||||
|
||||
@@ -802,7 +811,7 @@ func (d *Downloader) Stats(id string) (sr any, err error) {
|
||||
}
|
||||
|
||||
func (d *Downloader) doDelete(task *Task, force bool) (err error) {
|
||||
defer d.unpinGBlobTask(task)
|
||||
defer d.releaseGBlobTask(task)
|
||||
err = func() error {
|
||||
if err := d.storage.Delete(bucketTask, task.ID); err != nil {
|
||||
return err
|
||||
@@ -1073,7 +1082,7 @@ func (d *Downloader) watch(task *Task) {
|
||||
d.emit(EventKeyDone, task)
|
||||
d.emit(EventKeyFinally, task, err)
|
||||
d.notifyRunning()
|
||||
d.unpinGBlobTask(task)
|
||||
d.releaseGBlobTask(task)
|
||||
d.triggerOnDone(task)
|
||||
d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
||||
d.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||
@@ -1141,9 +1150,11 @@ func (d *Downloader) watch(task *Task) {
|
||||
func (d *Downloader) doOnError(task *Task, err error) {
|
||||
d.Logger.Warn().Err(err).Msgf("task download failed, task id: %s", task.ID)
|
||||
task.updateStatus(base.DownloadStatusError)
|
||||
d.unpinGBlobTask(task)
|
||||
d.triggerOnError(task, err)
|
||||
if task.Status == base.DownloadStatusError {
|
||||
if !d.isResumableGBlobTask(task) {
|
||||
d.releaseGBlobTask(task)
|
||||
}
|
||||
d.emit(EventKeyError, task, err)
|
||||
d.emit(EventKeyFinally, task, err)
|
||||
d.notifyRunning()
|
||||
@@ -1281,7 +1292,7 @@ func (d *Downloader) statusMut(task *Task, fn func() (bool, error)) (bool, error
|
||||
}
|
||||
|
||||
func (d *Downloader) doStart(task *Task) (err error) {
|
||||
var needCreate bool
|
||||
var isCreate bool
|
||||
isReturn, err := d.statusMut(task, func() (isReturn bool, err error) {
|
||||
if task.Status == base.DownloadStatusRunning || task.Status == base.DownloadStatusDone {
|
||||
isReturn = true
|
||||
@@ -1293,7 +1304,7 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
||||
d.Logger.Error().Stack().Err(err).Msgf("restore fetcher failed, task id: %s", task.ID)
|
||||
return
|
||||
}
|
||||
needCreate = !task.IsCreated
|
||||
isCreate = task.Status == base.DownloadStatusReady
|
||||
task.updateStatus(base.DownloadStatusRunning)
|
||||
|
||||
return
|
||||
@@ -1311,6 +1322,11 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
||||
defer task.lock.Unlock()
|
||||
|
||||
d.triggerOnStart(task)
|
||||
if task.fetcher != nil {
|
||||
task.fetcher.Meta().Req = task.Meta.Req
|
||||
task.fetcher.Meta().Res = task.Meta.Res
|
||||
task.fetcher.Meta().Opts = task.Meta.Opts
|
||||
}
|
||||
if task.Meta.Res == nil {
|
||||
err := task.fetcher.Resolve(task.Meta.Req, task.Meta.Opts)
|
||||
if err != nil {
|
||||
@@ -1319,7 +1335,7 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
||||
task.Meta.Res = task.fetcher.Meta().Res
|
||||
}
|
||||
|
||||
if needCreate {
|
||||
if isCreate {
|
||||
if task.fetcherManager.AutoRename() {
|
||||
d.checkDuplicateLock.Lock()
|
||||
defer d.checkDuplicateLock.Unlock()
|
||||
@@ -1343,9 +1359,10 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
||||
task.Meta.Opts.Name = newName
|
||||
}
|
||||
}
|
||||
task.IsCreated = true
|
||||
|
||||
task.Meta.Res.CalcSize(task.Meta.Opts.SelectFiles)
|
||||
}
|
||||
|
||||
task.Progress.Speed = 0
|
||||
task.timer.Start()
|
||||
if err := task.fetcher.Start(); err != nil {
|
||||
@@ -1842,6 +1859,28 @@ func (d *Downloader) unpinGBlobTask(task *Task) {
|
||||
d.gblob.Unpin(task.Meta.Req.URL)
|
||||
}
|
||||
|
||||
func (d *Downloader) isResumableGBlobTask(task *Task) bool {
|
||||
if task == nil || task.Meta == nil || task.Meta.Res == nil {
|
||||
return false
|
||||
}
|
||||
return task.Protocol == protogblob.Scheme && task.Meta.Res.Range
|
||||
}
|
||||
|
||||
func (d *Downloader) revokeGBlobTask(task *Task) {
|
||||
if d.gblob == nil || task == nil || task.Meta == nil || task.Meta.Req == nil || task.Meta.Req.URL == "" {
|
||||
return
|
||||
}
|
||||
if task.Protocol != protogblob.Scheme {
|
||||
return
|
||||
}
|
||||
_ = d.gblob.Revoke(task.Meta.Req.URL)
|
||||
}
|
||||
|
||||
func (d *Downloader) releaseGBlobTask(task *Task) {
|
||||
d.revokeGBlobTask(task)
|
||||
d.unpinGBlobTask(task)
|
||||
}
|
||||
|
||||
var defaultDownloader = NewDownloader(nil)
|
||||
|
||||
type boot struct {
|
||||
|
||||
@@ -21,12 +21,14 @@ import (
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
)
|
||||
|
||||
var testDownloadOpt = &base.Options{
|
||||
Path: test.Dir,
|
||||
Name: test.DownloadName,
|
||||
Extra: http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
func newTestDownloadOpt() *base.Options {
|
||||
return &base.Options{
|
||||
Path: test.Dir,
|
||||
Name: test.DownloadName,
|
||||
Extra: http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_Resolve(t *testing.T) {
|
||||
@@ -61,6 +63,51 @@ func TestDownloader_Resolve(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_UsesStorageDirForGBlobRegistry(t *testing.T) {
|
||||
storageDir := t.TempDir()
|
||||
downloader := NewDownloader(&DownloaderConfig{
|
||||
Storage: NewMemStorage(),
|
||||
StorageDir: storageDir,
|
||||
})
|
||||
if err := downloader.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer downloader.Clear()
|
||||
|
||||
if downloader.gblob == nil {
|
||||
t.Fatal("expected gblob registry to be initialized")
|
||||
}
|
||||
expectedDir := filepath.Join(storageDir, "gblob")
|
||||
if filepath.Clean(downloader.gblob.Dir()) != filepath.Clean(expectedDir) {
|
||||
t.Fatalf("expected gblob registry dir under storage dir, got %s want %s", downloader.gblob.Dir(), expectedDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_SetupCleansStaleGBlobDir(t *testing.T) {
|
||||
storageDir := t.TempDir()
|
||||
gblobDir := filepath.Join(storageDir, "gblob")
|
||||
if err := os.MkdirAll(gblobDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
staleFile := filepath.Join(gblobDir, "stale")
|
||||
if err := os.WriteFile(staleFile, []byte("stale"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
downloader := NewDownloader(&DownloaderConfig{
|
||||
Storage: NewMemStorage(),
|
||||
StorageDir: storageDir,
|
||||
})
|
||||
if err := downloader.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer downloader.Clear()
|
||||
|
||||
if _, err := os.Stat(staleFile); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected stale gblob file to be removed on setup, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_Create(t *testing.T) {
|
||||
listener := test.StartTestFileServer()
|
||||
defer listener.Close()
|
||||
@@ -73,7 +120,7 @@ func TestDownloader_Create(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
rr, err := downloader.Resolve(req, testDownloadOpt)
|
||||
rr, err := downloader.Resolve(req, newTestDownloadOpt())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -113,7 +160,7 @@ func TestDownloader_CreateNotInWhite(t *testing.T) {
|
||||
}
|
||||
// With new fetcher design, white list check happens during Resolve (not Create)
|
||||
// because Resolve now requires Options which includes the download path
|
||||
_, err := downloader.Resolve(req, testDownloadOpt)
|
||||
_, err := downloader.Resolve(req, newTestDownloadOpt())
|
||||
if err == nil {
|
||||
t.Error("TestDownloader_CreateNotInWhite() expected error but got nil")
|
||||
}
|
||||
@@ -162,7 +209,7 @@ func TestDownloader_CreateDirectBatch(t *testing.T) {
|
||||
|
||||
_, err := downloader.CreateDirectBatch(&base.CreateTaskBatch{
|
||||
Reqs: reqs,
|
||||
Opts: testDownloadOpt,
|
||||
Opts: newTestDownloadOpt(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -266,6 +313,9 @@ func TestDownloader_CreateWithProxy(t *testing.T) {
|
||||
}
|
||||
|
||||
func doTestDownloaderCreateWithProxy(t *testing.T, auth bool, buildReqProxy func(reqProxy *base.RequestProxy) *base.RequestProxy, buildProxyConfig func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig, errHandler func(err error)) {
|
||||
listener := test.StartTestFileServer()
|
||||
defer listener.Close()
|
||||
|
||||
usr, pwd := "", ""
|
||||
if auth {
|
||||
usr, pwd = "admin", "123"
|
||||
@@ -291,7 +341,7 @@ func doTestDownloaderCreateWithProxy(t *testing.T, auth bool, buildReqProxy func
|
||||
downloader.cfg.DownloaderStoreConfig.Proxy = globalProxyCfg
|
||||
|
||||
req := &base.Request{
|
||||
URL: test.ExternalDownloadUrl,
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
if buildReqProxy != nil {
|
||||
req.Proxy = buildReqProxy(&base.RequestProxy{
|
||||
@@ -310,13 +360,13 @@ func doTestDownloaderCreateWithProxy(t *testing.T, auth bool, buildReqProxy func
|
||||
return
|
||||
}
|
||||
want := &base.Resource{
|
||||
Size: test.ExternalDownloadSize,
|
||||
Size: test.BuildSize,
|
||||
Range: true,
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: test.ExternalDownloadName,
|
||||
Name: test.BuildName,
|
||||
Path: "",
|
||||
Size: test.ExternalDownloadSize,
|
||||
Size: test.BuildSize,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -384,7 +434,7 @@ func TestDownloader_StoreAndRestore(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
rr, err := downloader.Resolve(req, testDownloadOpt)
|
||||
rr, err := downloader.Resolve(req, newTestDownloadOpt())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -653,7 +703,7 @@ func TestDownloader_Stats(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
rr, err := downloader.Resolve(req, testDownloadOpt)
|
||||
rr, err := downloader.Resolve(req, newTestDownloadOpt())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -708,7 +758,7 @@ func TestDownloader_Delete(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
taskId, err := downloader.CreateDirect(req, testDownloadOpt)
|
||||
taskId, err := downloader.CreateDirect(req, newTestDownloadOpt())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -772,7 +822,7 @@ func TestDownloader_PauseAndContinue(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
rr, err := downloader.Resolve(req, testDownloadOpt)
|
||||
rr, err := downloader.Resolve(req, newTestDownloadOpt())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -843,7 +893,7 @@ func TestDownloader_PauseAllAndContinueAll(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
rr, err := downloader.Resolve(req, testDownloadOpt)
|
||||
rr, err := downloader.Resolve(req, newTestDownloadOpt())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+107
-43
@@ -3,6 +3,8 @@ package engine
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
||||
@@ -14,7 +16,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja_nodejs/eventloop"
|
||||
gojaurl "github.com/dop251/goja_nodejs/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed polyfill/out/index.js
|
||||
@@ -26,6 +27,8 @@ type Engine struct {
|
||||
Runtime *goja.Runtime
|
||||
}
|
||||
|
||||
type JSFunction func(goja.FunctionCall) goja.Value
|
||||
|
||||
// RunString executes the script and returns the go type value
|
||||
// if script result is promise, it will be resolved
|
||||
func (e *Engine) RunString(script string) (value any, err error) {
|
||||
@@ -36,37 +39,88 @@ func (e *Engine) RunString(script string) (value any, err error) {
|
||||
|
||||
// CallFunction calls the function and returns the go type value
|
||||
// if function result is promise, it will be resolved
|
||||
func (e *Engine) CallFunction(fn goja.Callable, args ...any) (value any, err error) {
|
||||
func (e *Engine) CallFunction(fn any, args ...any) (value any, err error) {
|
||||
return e.runOnLoop(func(runtime *goja.Runtime) (goja.Value, error) {
|
||||
if args == nil {
|
||||
return fn(nil)
|
||||
}
|
||||
var jsArgs []goja.Value
|
||||
for _, arg := range args {
|
||||
jsArgs = append(jsArgs, runtime.ToValue(arg))
|
||||
}
|
||||
return fn(nil, jsArgs...)
|
||||
switch f := fn.(type) {
|
||||
case goja.Callable:
|
||||
if args == nil {
|
||||
return f(nil)
|
||||
}
|
||||
return f(nil, jsArgs...)
|
||||
case JSFunction:
|
||||
return callExportedFunction(func(call goja.FunctionCall) goja.Value {
|
||||
return f(call)
|
||||
}, jsArgs...)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported function type: %T", fn)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Engine) runOnLoop(fn func(runtime *goja.Runtime) (goja.Value, error)) (any, error) {
|
||||
type result struct {
|
||||
value goja.Value
|
||||
value any
|
||||
err error
|
||||
}
|
||||
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 {
|
||||
if err, ok := r.(error); ok {
|
||||
ch <- result{err: err}
|
||||
return
|
||||
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)})
|
||||
}
|
||||
ch <- result{err: errors.New("panic")}
|
||||
}
|
||||
}()
|
||||
value, err := fn(runtime)
|
||||
ch <- result{value: value, err: err}
|
||||
if err != nil {
|
||||
sendResult(result{err: err})
|
||||
return
|
||||
}
|
||||
if p, ok := value.Export().(*goja.Promise); ok {
|
||||
switch p.State() {
|
||||
case goja.PromiseStateFulfilled:
|
||||
sendResult(result{value: exportJSValue(p.Result())})
|
||||
return
|
||||
case goja.PromiseStateRejected:
|
||||
sendResult(result{err: exportJSError(p.Result())})
|
||||
return
|
||||
}
|
||||
promiseObj := value.ToObject(runtime)
|
||||
thenVal := promiseObj.Get("then")
|
||||
thenFn, ok := goja.AssertFunction(thenVal)
|
||||
if !ok {
|
||||
sendResult(result{err: errors.New("promise.then is not callable")})
|
||||
return
|
||||
}
|
||||
onFulfilled := runtime.ToValue(func(call goja.FunctionCall) goja.Value {
|
||||
sendResult(result{value: exportJSValue(call.Argument(0))})
|
||||
return goja.Undefined()
|
||||
})
|
||||
onRejected := runtime.ToValue(func(call goja.FunctionCall) goja.Value {
|
||||
sendResult(result{err: exportJSError(call.Argument(0))})
|
||||
return goja.Undefined()
|
||||
})
|
||||
if _, err := thenFn(promiseObj, onFulfilled, onRejected); err != nil {
|
||||
sendResult(result{err: err})
|
||||
}
|
||||
return
|
||||
}
|
||||
sendResult(result{value: exportJSValue(value)})
|
||||
})
|
||||
if !ok {
|
||||
return nil, errors.New("engine loop terminated")
|
||||
@@ -75,7 +129,7 @@ func (e *Engine) runOnLoop(fn func(runtime *goja.Runtime) (goja.Value, error)) (
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
return resolveResult(res.value)
|
||||
return res.value, nil
|
||||
}
|
||||
|
||||
func (e *Engine) Close() {
|
||||
@@ -130,7 +184,7 @@ func NewEngine(cfg *Config) *Engine {
|
||||
if _, err := runtime.RunString("global.location = new URL('http://localhost');"); err != nil {
|
||||
return
|
||||
}
|
||||
if err := stream.Enable(runtime, cfg.StreamConfig); err != nil {
|
||||
if err := stream.Enable(runtime, loop, cfg.StreamConfig); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
@@ -144,35 +198,45 @@ func Run(script string) (value any, err error) {
|
||||
return engine.RunString(script)
|
||||
}
|
||||
|
||||
// if the value is Promise, it will be resolved and return the result.
|
||||
func resolveResult(value goja.Value) (any, error) {
|
||||
export := value.Export()
|
||||
switch export.(type) {
|
||||
case *goja.Promise:
|
||||
p := export.(*goja.Promise)
|
||||
for p.State() == goja.PromiseStatePending {
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
}
|
||||
switch p.State() {
|
||||
case goja.PromiseStatePending:
|
||||
return nil, nil
|
||||
case goja.PromiseStateFulfilled:
|
||||
return p.Result().Export(), nil
|
||||
case goja.PromiseStateRejected:
|
||||
if err, ok := p.Result().Export().(error); ok {
|
||||
return nil, err
|
||||
} else {
|
||||
stack := p.Result().String()
|
||||
result := p.Result()
|
||||
if ro, ok := result.(*goja.Object); ok {
|
||||
stackVal := ro.Get("stack")
|
||||
if stackVal != nil && stackVal.String() != "" {
|
||||
stack = stackVal.String()
|
||||
}
|
||||
}
|
||||
return nil, errors.New(stack)
|
||||
}
|
||||
func exportJSValue(value goja.Value) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return value.Export()
|
||||
}
|
||||
|
||||
func exportJSError(value goja.Value) error {
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return errors.New("promise rejected")
|
||||
}
|
||||
if err, ok := value.Export().(error); ok {
|
||||
return err
|
||||
}
|
||||
stack := value.String()
|
||||
if ro, ok := value.(*goja.Object); ok {
|
||||
stackVal := ro.Get("stack")
|
||||
if stackVal != nil && stackVal.String() != "" {
|
||||
stack = stackVal.String()
|
||||
}
|
||||
}
|
||||
return export, nil
|
||||
return errors.New(stack)
|
||||
}
|
||||
|
||||
func callExportedFunction(fn func(goja.FunctionCall) goja.Value, args ...goja.Value) (ret goja.Value, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
switch v := r.(type) {
|
||||
case error:
|
||||
err = v
|
||||
case goja.Value:
|
||||
err = exportJSError(v)
|
||||
default:
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return fn(goja.FunctionCall{
|
||||
This: nil,
|
||||
Arguments: args,
|
||||
}), nil
|
||||
}
|
||||
|
||||
@@ -26,7 +26,11 @@ func TestPolyfill(t *testing.T) {
|
||||
doTestPolyfill(t, "MessageError")
|
||||
doTestPolyfill(t, "XMLHttpRequest")
|
||||
doTestPolyfill(t, "Blob")
|
||||
doTestPolyfill(t, "Event")
|
||||
doTestPolyfill(t, "EventTarget")
|
||||
doTestPolyfill(t, "FormData")
|
||||
doTestPolyfill(t, "AbortController")
|
||||
doTestPolyfill(t, "AbortSignal")
|
||||
doTestPolyfill(t, "TextDecoder")
|
||||
doTestPolyfill(t, "TextEncoder")
|
||||
doTestPolyfill(t, "fetch")
|
||||
@@ -36,13 +40,90 @@ func TestPolyfill(t *testing.T) {
|
||||
func TestError(t *testing.T) {
|
||||
engine := NewEngine(nil)
|
||||
_, err := engine.RunString(`
|
||||
throw new MessageError('test');
|
||||
throw new MessageError('test');
|
||||
`)
|
||||
if me, ok := gojautil.AssertError[*gojaerror.MessageError](err); !ok {
|
||||
t.Fatalf("expect MessageError, but got %v", me)
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLCreateObjectURLRejectsUnsupportedType(t *testing.T) {
|
||||
engine := NewEngine(nil)
|
||||
defer engine.Close()
|
||||
|
||||
_, err := engine.RunString(`
|
||||
URL.createObjectURL({});
|
||||
`)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported type error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Unsupported object type for URL.createObjectURL: Object") {
|
||||
t.Fatalf("unexpected unsupported type error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallFunction_DetachedAsyncWorkDoesNotBlockReturn(t *testing.T) {
|
||||
engine := NewEngine(nil)
|
||||
defer engine.Close()
|
||||
if _, err := engine.RunString(`
|
||||
globalThis.__done = false;
|
||||
async function testDetached() {
|
||||
(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
globalThis.__done = true;
|
||||
})();
|
||||
return "ok";
|
||||
}
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fn, ok := goja.AssertFunction(engine.Runtime.Get("testDetached"))
|
||||
if !ok {
|
||||
t.Fatal("testDetached is not callable")
|
||||
}
|
||||
startedAt := time.Now()
|
||||
value, err := engine.CallFunction(fn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
elapsed := time.Since(startedAt)
|
||||
if elapsed > 200*time.Millisecond {
|
||||
t.Fatalf("detached async work blocked return for %s", elapsed)
|
||||
}
|
||||
if value != "ok" {
|
||||
t.Fatalf("unexpected return value: %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallFunction_AwaitedPromiseBlocksUntilSettled(t *testing.T) {
|
||||
engine := NewEngine(nil)
|
||||
defer engine.Close()
|
||||
if _, err := engine.RunString(`
|
||||
async function testAwaited() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
return "ok";
|
||||
}
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fn, ok := goja.AssertFunction(engine.Runtime.Get("testAwaited"))
|
||||
if !ok {
|
||||
t.Fatal("testAwaited is not callable")
|
||||
}
|
||||
startedAt := time.Now()
|
||||
value, err := engine.CallFunction(fn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
elapsed := time.Since(startedAt)
|
||||
if elapsed < 100*time.Millisecond {
|
||||
t.Fatalf("awaited promise returned too early: %s", elapsed)
|
||||
}
|
||||
if value != "ok" {
|
||||
t.Fatalf("unexpected return value: %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetch(t *testing.T) {
|
||||
server := startServer()
|
||||
defer server.Close()
|
||||
@@ -64,6 +145,50 @@ async function testText(){
|
||||
return await resp.text();
|
||||
}
|
||||
|
||||
async function testResponseBodyGetReader() {
|
||||
const resp = await fetch(host+'/text', {
|
||||
method: 'POST',
|
||||
body: 'stream-body'
|
||||
});
|
||||
if (!resp.body || typeof resp.body.getReader !== 'function') {
|
||||
throw new Error('response.body.getReader is not available');
|
||||
}
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let result = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
result += decoder.decode(value, { stream: true });
|
||||
}
|
||||
result += decoder.decode();
|
||||
return result;
|
||||
}
|
||||
|
||||
async function testStreamingFetchResponseBody() {
|
||||
const startedAt = Date.now();
|
||||
const resp = await fetch(host+'/stream');
|
||||
const resolvedAt = Date.now() - startedAt;
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const first = await reader.read();
|
||||
const firstAt = Date.now() - startedAt;
|
||||
const second = await reader.read();
|
||||
const secondAt = Date.now() - startedAt;
|
||||
let result = '';
|
||||
result += decoder.decode(first.value, { stream: true });
|
||||
result += decoder.decode(second.value, { stream: true });
|
||||
result += decoder.decode();
|
||||
return {
|
||||
resolvedAt,
|
||||
firstAt,
|
||||
secondAt,
|
||||
text: result
|
||||
};
|
||||
}
|
||||
|
||||
async function testOctetStream(file){
|
||||
const resp = await fetch(host+'/octetStream',{
|
||||
method: 'POST',
|
||||
@@ -251,6 +376,32 @@ async function testFingerprintSafari(){
|
||||
t.Fatalf("testText failed, want %s, got %s", "test", result)
|
||||
}
|
||||
|
||||
result, err = callTestFun(engine, "testResponseBodyGetReader")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result != "stream-body" {
|
||||
t.Fatalf("testResponseBodyGetReader failed, want %s, got %s", "stream-body", result)
|
||||
}
|
||||
|
||||
result, err = callTestFun(engine, "testStreamingFetchResponseBody")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
streaming := result.(map[string]any)
|
||||
if streaming["text"] != "chunk-1chunk-2" {
|
||||
t.Fatalf("testStreamingFetchResponseBody failed, want %s, got %v", "chunk-1chunk-2", streaming["text"])
|
||||
}
|
||||
if int(streaming["resolvedAt"].(int64)) >= 120 {
|
||||
t.Fatalf("fetch resolved too late, got %dms", streaming["resolvedAt"].(int64))
|
||||
}
|
||||
if int(streaming["firstAt"].(int64)) >= 120 {
|
||||
t.Fatalf("first chunk arrived too late, got %dms", streaming["firstAt"].(int64))
|
||||
}
|
||||
if int(streaming["secondAt"].(int64)) < 120 {
|
||||
t.Fatalf("second chunk arrived too early, got %dms", streaming["secondAt"].(int64))
|
||||
}
|
||||
|
||||
func() {
|
||||
jsFile, _, md5 := buildFile(t, engine.Runtime)
|
||||
result, err = callTestFun(engine, "testOctetStream", jsFile)
|
||||
@@ -464,6 +615,18 @@ func startServer() net.Listener {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(buf)
|
||||
})
|
||||
mux.HandleFunc("/stream", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
_, _ = w.Write([]byte("chunk-1"))
|
||||
flusher.Flush()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
_, _ = w.Write([]byte("chunk-2"))
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("chunk-1chunk-2"))
|
||||
})
|
||||
mux.HandleFunc("/octetStream", func(w http.ResponseWriter, r *http.Request) {
|
||||
md5 := calcMd5(r.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
protogblob "github.com/GopeedLab/gopeed/internal/protocol/gblob"
|
||||
"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/xhr"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/util"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja_nodejs/eventloop"
|
||||
"github.com/imroc/req/v3"
|
||||
)
|
||||
|
||||
//go:embed stream.js
|
||||
@@ -12,36 +28,57 @@ var script string
|
||||
|
||||
type Config struct {
|
||||
CreateBlobObjectURL func(data []byte, contentType string) (string, error)
|
||||
CreateWritableStreamObjectURL func() (string, error)
|
||||
CreateWritableStreamObjectURL func(opts *WritableStreamObjectURLOptions) (string, error)
|
||||
RegisterWritableStreamResume func(url string, reopen func(offset int64) error) error
|
||||
WriteWritableStreamObjectURL func(url string, data any) error
|
||||
CloseWritableStreamObjectURL func(url string) error
|
||||
AbortWritableStreamObjectURL func(url string, reason string) error
|
||||
RevokeObjectURL func(url string) error
|
||||
ProxyHandler func(r *http.Request) (*url.URL, error)
|
||||
}
|
||||
|
||||
func Enable(runtime *goja.Runtime, cfg *Config) error {
|
||||
type WritableStreamObjectURLOptions struct {
|
||||
Reopenable bool
|
||||
}
|
||||
|
||||
func isIgnorableGBlobError(err error) bool {
|
||||
return errors.Is(err, protogblob.ErrSourceRevoked) ||
|
||||
errors.Is(err, protogblob.ErrSourceNotFound) ||
|
||||
errors.Is(err, protogblob.ErrSourceClosed) ||
|
||||
errors.Is(err, protogblob.ErrSourceAborted)
|
||||
}
|
||||
|
||||
func Enable(runtime *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error {
|
||||
if cfg == nil {
|
||||
cfg = &Config{}
|
||||
}
|
||||
if err := runtime.Set("__gopeed_create_blob_object_url", func(data []byte, contentType string) string {
|
||||
if cfg.CreateBlobObjectURL == nil {
|
||||
panic(fmt.Errorf("gblob blob object url handler not configured"))
|
||||
panic(runtime.NewGoError(fmt.Errorf("gblob blob object url handler not configured")))
|
||||
}
|
||||
url, err := cfg.CreateBlobObjectURL(data, contentType)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
return url
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_create_writable_stream_object_url", func() string {
|
||||
if err := runtime.Set("__gopeed_create_writable_stream_object_url", func(reopenable bool) string {
|
||||
if cfg.CreateWritableStreamObjectURL == nil {
|
||||
panic(fmt.Errorf("gblob writable stream object url handler not configured"))
|
||||
panic(runtime.NewGoError(fmt.Errorf("gblob writable stream object url handler not configured")))
|
||||
}
|
||||
url, err := cfg.CreateWritableStreamObjectURL()
|
||||
opts := &WritableStreamObjectURLOptions{Reopenable: reopenable}
|
||||
url, err := cfg.CreateWritableStreamObjectURL(opts)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
if opts.Reopenable && cfg.RegisterWritableStreamResume != nil {
|
||||
if err := cfg.RegisterWritableStreamResume(url, func(offset int64) error {
|
||||
return openWritableStreamObjectURL(loop, url, offset)
|
||||
}); err != nil {
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
}
|
||||
return url
|
||||
}); err != nil {
|
||||
@@ -49,44 +86,413 @@ func Enable(runtime *goja.Runtime, cfg *Config) error {
|
||||
}
|
||||
if err := runtime.Set("__gopeed_write_writable_stream_object_url", func(url string, data any) {
|
||||
if cfg.WriteWritableStreamObjectURL == nil {
|
||||
panic(fmt.Errorf("gblob writable stream write handler not configured"))
|
||||
panic(runtime.NewGoError(fmt.Errorf("gblob writable stream write handler not configured")))
|
||||
}
|
||||
if err := cfg.WriteWritableStreamObjectURL(url, data); err != nil {
|
||||
panic(err)
|
||||
if isIgnorableGBlobError(err) {
|
||||
return
|
||||
}
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_close_writable_stream_object_url", func(url string) {
|
||||
if cfg.CloseWritableStreamObjectURL == nil {
|
||||
panic(fmt.Errorf("gblob writable stream close handler not configured"))
|
||||
panic(runtime.NewGoError(fmt.Errorf("gblob writable stream close handler not configured")))
|
||||
}
|
||||
if err := cfg.CloseWritableStreamObjectURL(url); err != nil {
|
||||
panic(err)
|
||||
if isIgnorableGBlobError(err) {
|
||||
return
|
||||
}
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_abort_writable_stream_object_url", func(url string, reason string) {
|
||||
if cfg.AbortWritableStreamObjectURL == nil {
|
||||
panic(fmt.Errorf("gblob writable stream abort handler not configured"))
|
||||
panic(runtime.NewGoError(fmt.Errorf("gblob writable stream abort handler not configured")))
|
||||
}
|
||||
if err := cfg.AbortWritableStreamObjectURL(url, reason); err != nil {
|
||||
panic(err)
|
||||
if isIgnorableGBlobError(err) {
|
||||
return
|
||||
}
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_revoke_object_url", func(url string) {
|
||||
if cfg.RevokeObjectURL == nil {
|
||||
panic(fmt.Errorf("gblob revoke handler not configured"))
|
||||
panic(runtime.NewGoError(fmt.Errorf("gblob revoke handler not configured")))
|
||||
}
|
||||
if err := cfg.RevokeObjectURL(url); err != nil {
|
||||
panic(err)
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
fetchRegistry := newFetchRegistry()
|
||||
if err := runtime.Set("__gopeed_fetch_open", func(call goja.FunctionCall) goja.Value {
|
||||
reqMeta, err := exportFetchRequest(runtime, call.Argument(0))
|
||||
if err != nil {
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
meta, err := fetchRegistry.Open(runtime, cfg.ProxyHandler, reqMeta)
|
||||
if err != nil {
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
return runtime.ToValue(meta)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_fetch_read", func(call goja.FunctionCall) goja.Value {
|
||||
id := call.Argument(0).String()
|
||||
chunkSize := int(call.Argument(1).ToInteger())
|
||||
chunk, done, err := fetchRegistry.Read(id, chunkSize)
|
||||
if err != nil {
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
if done {
|
||||
return goja.Null()
|
||||
}
|
||||
return runtime.ToValue(runtime.NewArrayBuffer(chunk))
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_fetch_close", func(id string) {
|
||||
fetchRegistry.Close(id)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_fetch_abort", func(id string, reason string) {
|
||||
fetchRegistry.Abort(id, reason)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := runtime.RunString(script)
|
||||
return err
|
||||
}
|
||||
|
||||
func openWritableStreamObjectURL(loop *eventloop.EventLoop, url string, offset int64) error {
|
||||
type result struct {
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
ok := loop.RunOnLoop(func(runtime *goja.Runtime) {
|
||||
send := func(err error) {
|
||||
select {
|
||||
case ch <- result{err: err}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
switch v := r.(type) {
|
||||
case error:
|
||||
send(v)
|
||||
case goja.Value:
|
||||
send(exportJSError(v))
|
||||
default:
|
||||
send(fmt.Errorf("panic: %v", r))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
fnVal := runtime.Get("__gopeed_open_writable_stream_object_url")
|
||||
fn, ok := goja.AssertFunction(fnVal)
|
||||
if !ok {
|
||||
send(fmt.Errorf("gblob writable stream open helper is not callable"))
|
||||
return
|
||||
}
|
||||
value, err := fn(nil, runtime.ToValue(url), runtime.ToValue(offset))
|
||||
if err != nil {
|
||||
send(err)
|
||||
return
|
||||
}
|
||||
if promise, ok := value.Export().(*goja.Promise); ok {
|
||||
switch promise.State() {
|
||||
case goja.PromiseStateFulfilled:
|
||||
send(nil)
|
||||
return
|
||||
case goja.PromiseStateRejected:
|
||||
send(exportJSError(promise.Result()))
|
||||
return
|
||||
default:
|
||||
send(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
send(nil)
|
||||
})
|
||||
if !ok {
|
||||
return errors.New("engine loop terminated")
|
||||
}
|
||||
return (<-ch).err
|
||||
}
|
||||
|
||||
func exportJSError(value goja.Value) error {
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return errors.New("promise rejected")
|
||||
}
|
||||
if err, ok := value.Export().(error); ok {
|
||||
return err
|
||||
}
|
||||
stack := value.String()
|
||||
if ro, ok := value.(*goja.Object); ok {
|
||||
stackVal := ro.Get("stack")
|
||||
if stackVal != nil && stackVal.String() != "" {
|
||||
stack = stackVal.String()
|
||||
}
|
||||
}
|
||||
return errors.New(stack)
|
||||
}
|
||||
|
||||
type fetchRegistry struct {
|
||||
mu sync.Mutex
|
||||
streams map[string]*fetchStream
|
||||
}
|
||||
|
||||
type fetchStream struct {
|
||||
body io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
type fetchRequest struct {
|
||||
URL string
|
||||
Method string
|
||||
Headers [][2]string
|
||||
Body any
|
||||
Redirect string
|
||||
Credentials string
|
||||
}
|
||||
|
||||
type fetchOpenMeta struct {
|
||||
ID string `json:"id"`
|
||||
Status int `json:"status"`
|
||||
StatusText string `json:"statusText"`
|
||||
URL string `json:"url"`
|
||||
Headers [][2]string `json:"headers"`
|
||||
}
|
||||
|
||||
func newFetchRegistry() *fetchRegistry {
|
||||
return &fetchRegistry{
|
||||
streams: make(map[string]*fetchStream),
|
||||
}
|
||||
}
|
||||
|
||||
func exportFetchRequest(runtime *goja.Runtime, value goja.Value) (*fetchRequest, error) {
|
||||
obj := value.ToObject(runtime)
|
||||
if obj == nil {
|
||||
return nil, fmt.Errorf("invalid fetch request")
|
||||
}
|
||||
meta := &fetchRequest{
|
||||
URL: obj.Get("url").String(),
|
||||
Method: obj.Get("method").String(),
|
||||
Redirect: obj.Get("redirect").String(),
|
||||
Credentials: obj.Get("credentials").String(),
|
||||
}
|
||||
if meta.Method == "" {
|
||||
meta.Method = http.MethodGet
|
||||
}
|
||||
if headersVal := obj.Get("headers"); headersVal != nil && !goja.IsUndefined(headersVal) && !goja.IsNull(headersVal) {
|
||||
if exported, ok := headersVal.Export().([]any); ok {
|
||||
for _, item := range exported {
|
||||
pair, ok := item.([]any)
|
||||
if !ok || len(pair) != 2 {
|
||||
continue
|
||||
}
|
||||
meta.Headers = append(meta.Headers, [2]string{fmt.Sprint(pair[0]), fmt.Sprint(pair[1])})
|
||||
}
|
||||
}
|
||||
}
|
||||
bodyVal := obj.Get("body")
|
||||
if bodyVal != nil && !goja.IsUndefined(bodyVal) && !goja.IsNull(bodyVal) {
|
||||
meta.Body = bodyVal.Export()
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func (r *fetchRegistry) Open(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL, error), reqMeta *fetchRequest) (*fetchOpenMeta, error) {
|
||||
client := req.C()
|
||||
if proxyHandler != nil {
|
||||
client.SetProxy(proxyHandler)
|
||||
}
|
||||
setFetchFingerprint(client, util.SafeGet[string](runtime, xhr.FingerprintMagicKey))
|
||||
contentType, body, err := buildFetchBody(reqMeta.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
reqBuilder := client.R()
|
||||
reqBuilder.SetContext(ctx)
|
||||
reqBuilder.DisableAutoReadResponse()
|
||||
for _, header := range reqMeta.Headers {
|
||||
reqBuilder.SetHeader(header[0], header[1])
|
||||
}
|
||||
if body != nil && reqMeta.Method != http.MethodGet && reqMeta.Method != http.MethodHead {
|
||||
reqBuilder.SetBody(body)
|
||||
if contentType != "" && !hasHeader(reqMeta.Headers, "Content-Type") {
|
||||
reqBuilder.SetHeader("Content-Type", contentType)
|
||||
}
|
||||
}
|
||||
client.SetRedirectPolicy(func(req *http.Request, via []*http.Request) error {
|
||||
switch reqMeta.Redirect {
|
||||
case "manual":
|
||||
return http.ErrUseLastResponse
|
||||
case "error":
|
||||
return fmt.Errorf("redirect failed")
|
||||
default:
|
||||
if len(via) > 20 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
})
|
||||
resp, err := reqBuilder.Send(reqMeta.Method, reqMeta.URL)
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errorsAsTimeout(err, &ne) {
|
||||
return nil, fmt.Errorf("Network request timed out")
|
||||
}
|
||||
return nil, fmt.Errorf("Network request failed: %w", err)
|
||||
}
|
||||
id := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
meta := &fetchOpenMeta{
|
||||
ID: id,
|
||||
Status: resp.StatusCode,
|
||||
StatusText: resp.Status,
|
||||
URL: reqMeta.URL,
|
||||
}
|
||||
if resp.Response != nil && resp.Response.Request != nil && resp.Response.Request.URL != nil {
|
||||
responseURL := *resp.Response.Request.URL
|
||||
responseURL.Fragment = ""
|
||||
meta.URL = responseURL.String()
|
||||
}
|
||||
for key, values := range resp.Header {
|
||||
meta.Headers = append(meta.Headers, [2]string{key, strings.Join(values, ", ")})
|
||||
}
|
||||
bodyCloser := io.NopCloser(strings.NewReader(""))
|
||||
if resp.Response != nil && resp.Response.Body != nil {
|
||||
bodyCloser = resp.Response.Body
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.streams[id] = &fetchStream{body: bodyCloser, cancel: cancel}
|
||||
r.mu.Unlock()
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func (r *fetchRegistry) Read(id string, chunkSize int) ([]byte, bool, error) {
|
||||
stream := r.get(id)
|
||||
if stream == nil {
|
||||
return nil, true, nil
|
||||
}
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = 64 * 1024
|
||||
}
|
||||
buf := make([]byte, chunkSize)
|
||||
n, err := stream.body.Read(buf)
|
||||
if n > 0 {
|
||||
return buf[:n], false, nil
|
||||
}
|
||||
if err == io.EOF {
|
||||
r.Close(id)
|
||||
return nil, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
r.Close(id)
|
||||
return nil, false, err
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (r *fetchRegistry) Close(id string) {
|
||||
r.mu.Lock()
|
||||
stream := r.streams[id]
|
||||
delete(r.streams, id)
|
||||
r.mu.Unlock()
|
||||
if stream != nil {
|
||||
stream.closeOnce.Do(func() {
|
||||
if stream.cancel != nil {
|
||||
stream.cancel()
|
||||
}
|
||||
_ = stream.body.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fetchRegistry) Abort(id string, _ string) {
|
||||
r.Close(id)
|
||||
}
|
||||
|
||||
func (r *fetchRegistry) get(id string) *fetchStream {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.streams[id]
|
||||
}
|
||||
|
||||
func buildFetchBody(body any) (string, any, error) {
|
||||
switch v := body.(type) {
|
||||
case nil:
|
||||
return "", nil, nil
|
||||
case string:
|
||||
return "text/plain;charset=UTF-8", v, nil
|
||||
case []byte:
|
||||
return "application/octet-stream", v, nil
|
||||
case goja.ArrayBuffer:
|
||||
return "application/octet-stream", v.Bytes(), nil
|
||||
case *file.File:
|
||||
return "application/octet-stream", v.Reader, nil
|
||||
case *formdata.FormData:
|
||||
pr, pw := io.Pipe()
|
||||
mw := xhr.NewMultipart(pw)
|
||||
for _, e := range v.Entries() {
|
||||
arr := e.([]any)
|
||||
key := arr[0].(string)
|
||||
val := arr[1]
|
||||
switch vv := val.(type) {
|
||||
case string:
|
||||
mw.WriteField(key, vv)
|
||||
case *file.File:
|
||||
mw.WriteFile(key, vv)
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
defer mw.Close()
|
||||
mw.Send()
|
||||
}()
|
||||
return mw.FormDataContentType(), pr, nil
|
||||
default:
|
||||
if typed, ok := v.(interface{ Bytes() []byte }); ok {
|
||||
return "application/octet-stream", typed.Bytes(), nil
|
||||
}
|
||||
return "", fmt.Sprint(v), nil
|
||||
}
|
||||
}
|
||||
|
||||
func hasHeader(headers [][2]string, key string) bool {
|
||||
for _, header := range headers {
|
||||
if strings.EqualFold(header[0], key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func setFetchFingerprint(client *req.Client, fingerprint string) {
|
||||
switch fingerprint {
|
||||
case "chrome":
|
||||
client.ImpersonateChrome()
|
||||
case "firefox":
|
||||
client.ImpersonateFirefox()
|
||||
case "safari":
|
||||
client.ImpersonateSafari()
|
||||
}
|
||||
}
|
||||
|
||||
func errorsAsTimeout(err error, target *net.Error) bool {
|
||||
return errors.As(err, target) && (*target).Timeout()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
const closeWritableObjectURL = globalThis.__gopeed_close_writable_stream_object_url;
|
||||
const abortWritableObjectURL = globalThis.__gopeed_abort_writable_stream_object_url;
|
||||
const revokeObjectURL = globalThis.__gopeed_revoke_object_url;
|
||||
const fetchOpen = globalThis.__gopeed_fetch_open;
|
||||
const fetchRead = globalThis.__gopeed_fetch_read;
|
||||
const fetchClose = globalThis.__gopeed_fetch_close;
|
||||
const fetchAbort = globalThis.__gopeed_fetch_abort;
|
||||
|
||||
if (typeof globalThis.ReadableStream === "undefined") {
|
||||
class ReadableStreamDefaultController {
|
||||
@@ -34,6 +38,13 @@
|
||||
return this._stream._read();
|
||||
}
|
||||
|
||||
cancel(reason) {
|
||||
if (!this._stream) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this._stream.cancel(reason);
|
||||
}
|
||||
|
||||
releaseLock() {
|
||||
if (this._stream) {
|
||||
this._stream._reader = null;
|
||||
@@ -45,15 +56,17 @@
|
||||
|
||||
class ReadableStream {
|
||||
constructor(source = {}) {
|
||||
this._source = source;
|
||||
this._queue = [];
|
||||
this._waiters = [];
|
||||
this._closed = false;
|
||||
this._errored = null;
|
||||
this._reader = null;
|
||||
this._pulling = false;
|
||||
this.locked = false;
|
||||
const controller = new ReadableStreamDefaultController(this);
|
||||
this._controller = new ReadableStreamDefaultController(this);
|
||||
if (typeof source.start === "function") {
|
||||
source.start(controller);
|
||||
source.start(this._controller);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +100,14 @@
|
||||
if (this._errored) {
|
||||
return Promise.reject(this._errored);
|
||||
}
|
||||
this._markBodyUsed();
|
||||
if (this._queue.length > 0) {
|
||||
return Promise.resolve({ done: false, value: this._queue.shift() });
|
||||
}
|
||||
if (this._closed) {
|
||||
return Promise.resolve({ done: true, value: undefined });
|
||||
}
|
||||
this._maybePull();
|
||||
if (this._queue.length > 0) {
|
||||
return Promise.resolve({ done: false, value: this._queue.shift() });
|
||||
}
|
||||
@@ -95,9 +116,39 @@
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
this._waiters.push({ resolve, reject });
|
||||
this._maybePull();
|
||||
});
|
||||
}
|
||||
|
||||
_maybePull() {
|
||||
if (this._closed || this._errored || this._pulling) {
|
||||
return;
|
||||
}
|
||||
if (this._queue.length > 0) {
|
||||
return;
|
||||
}
|
||||
if (!this._source || typeof this._source.pull !== "function") {
|
||||
return;
|
||||
}
|
||||
this._pulling = true;
|
||||
Promise.resolve(this._source.pull(this._controller))
|
||||
.catch((err) => {
|
||||
this._error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
this._pulling = false;
|
||||
if (!this._closed && !this._errored && this._queue.length === 0 && this._waiters.length > 0) {
|
||||
this._maybePull();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_markBodyUsed() {
|
||||
if (typeof this.__gopeedMarkBodyUsed === "function") {
|
||||
this.__gopeedMarkBodyUsed();
|
||||
}
|
||||
}
|
||||
|
||||
getReader() {
|
||||
if (this.locked) {
|
||||
throw new TypeError("ReadableStream is locked");
|
||||
@@ -107,6 +158,17 @@
|
||||
return this._reader;
|
||||
}
|
||||
|
||||
cancel(reason) {
|
||||
this._markBodyUsed();
|
||||
if (this._source && typeof this._source.cancel === "function") {
|
||||
return Promise.resolve(this._source.cancel(reason)).then(() => {
|
||||
this._close();
|
||||
});
|
||||
}
|
||||
this._close();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async pipeTo(dest) {
|
||||
const reader = this.getReader();
|
||||
const writer = dest.getWriter();
|
||||
@@ -293,44 +355,436 @@
|
||||
};
|
||||
}
|
||||
|
||||
function toUint8Array(chunk) {
|
||||
if (chunk == null) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
if (chunk instanceof Uint8Array) {
|
||||
return chunk;
|
||||
}
|
||||
if (typeof chunk === "string") {
|
||||
return new TextEncoder().encode(chunk);
|
||||
}
|
||||
if (chunk instanceof ArrayBuffer) {
|
||||
return new Uint8Array(chunk);
|
||||
}
|
||||
if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView && ArrayBuffer.isView(chunk)) {
|
||||
return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
||||
}
|
||||
if (typeof Blob !== "undefined" && chunk instanceof Blob) {
|
||||
if (chunk._buffer instanceof Uint8Array) {
|
||||
return chunk._buffer;
|
||||
}
|
||||
}
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
function createBodyReadableStream(owner) {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
Promise.resolve().then(async () => {
|
||||
owner.bodyUsed = true;
|
||||
if (owner._noBody) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
if (owner._bodyArrayBuffer) {
|
||||
controller.enqueue(toUint8Array(owner._bodyArrayBuffer));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
if (owner._bodyBlob) {
|
||||
const data = await owner._bodyBlob.arrayBuffer();
|
||||
controller.enqueue(new Uint8Array(data));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
if (owner._bodyText != null) {
|
||||
controller.enqueue(toUint8Array(owner._bodyText));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
if (owner._bodyInit != null) {
|
||||
controller.enqueue(toUint8Array(owner._bodyInit));
|
||||
}
|
||||
controller.close();
|
||||
}).catch((err) => {
|
||||
controller.error(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function readAllFromStream(stream, asText) {
|
||||
if (!stream) {
|
||||
return asText ? "" : new Uint8Array(0);
|
||||
}
|
||||
const reader = stream.getReader();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
const chunk = toUint8Array(value);
|
||||
chunks.push(chunk);
|
||||
total += chunk.byteLength;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
if (asText) {
|
||||
return new TextDecoder().decode(merged);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function createStreamBackedBlob(stream, contentType, size) {
|
||||
const blob = new Blob([], { type: contentType || "" });
|
||||
if (Number.isFinite(size) && size >= 0) {
|
||||
try {
|
||||
Object.defineProperty(blob, "size", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: size,
|
||||
});
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
blob.__gopeedStreamBackedBlob = true;
|
||||
blob.__gopeedBodyStream = stream;
|
||||
blob.stream = function () {
|
||||
const bodyStream = this.__gopeedBodyStream;
|
||||
this.__gopeedBodyStream = null;
|
||||
if (bodyStream instanceof ReadableStream) {
|
||||
return bodyStream;
|
||||
}
|
||||
return new Blob([], { type: this.type || "" }).stream();
|
||||
};
|
||||
blob.arrayBuffer = async function () {
|
||||
const bytes = await readAllFromStream(this.stream(), false);
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
};
|
||||
blob.text = async function () {
|
||||
return readAllFromStream(this.stream(), true);
|
||||
};
|
||||
return blob;
|
||||
}
|
||||
|
||||
function attachResponseStreaming(response, stream) {
|
||||
response.__gopeedBodyStream = stream;
|
||||
response.__gopeedBodyConsumed = false;
|
||||
const ensureUnused = () => {
|
||||
if (response.__gopeedBodyConsumed) {
|
||||
throw new TypeError("Already read");
|
||||
}
|
||||
};
|
||||
const markBodyUsed = () => {
|
||||
ensureUnused();
|
||||
response.__gopeedBodyConsumed = true;
|
||||
response.bodyUsed = true;
|
||||
};
|
||||
if (stream) {
|
||||
stream.__gopeedMarkBodyUsed = () => {
|
||||
if (!response.__gopeedBodyConsumed) {
|
||||
response.__gopeedBodyConsumed = true;
|
||||
response.bodyUsed = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
response.text = async function () {
|
||||
ensureUnused();
|
||||
markBodyUsed();
|
||||
return readAllFromStream(stream, true);
|
||||
};
|
||||
response.arrayBuffer = async function () {
|
||||
ensureUnused();
|
||||
markBodyUsed();
|
||||
const bytes = await readAllFromStream(stream, false);
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
};
|
||||
response.blob = async function () {
|
||||
ensureUnused();
|
||||
markBodyUsed();
|
||||
const contentType = this.headers && this.headers.get ? (this.headers.get("content-type") || "") : "";
|
||||
const contentLength = this.headers && this.headers.get ? parseInt(this.headers.get("content-length") || "", 10) : NaN;
|
||||
return createStreamBackedBlob(stream, contentType, contentLength);
|
||||
};
|
||||
response.json = async function () {
|
||||
const text = await this.text();
|
||||
return JSON.parse(text);
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
if (typeof globalThis.Response === "function") {
|
||||
const responseProto = globalThis.Response.prototype;
|
||||
const bodyDescriptor = Object.getOwnPropertyDescriptor(responseProto, "body");
|
||||
if (!bodyDescriptor || typeof bodyDescriptor.get !== "function") {
|
||||
Object.defineProperty(responseProto, "body", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
if (this.__gopeedBodyStream) {
|
||||
return this.__gopeedBodyStream;
|
||||
}
|
||||
if (!this.__gopeedBodyStream) {
|
||||
this.__gopeedBodyStream = createBodyReadableStream(this);
|
||||
}
|
||||
return this.__gopeedBodyStream;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const originalCreateObjectURL = typeof URL.createObjectURL === "function"
|
||||
? URL.createObjectURL.bind(URL)
|
||||
: null;
|
||||
const originalRevokeObjectURL = typeof URL.revokeObjectURL === "function"
|
||||
? URL.revokeObjectURL.bind(URL)
|
||||
: null;
|
||||
const resumableReadableObjectURLs = new Map();
|
||||
const activeReadableObjectURLs = new Map();
|
||||
const readableObjectURLYieldBytes = 256 * 1024;
|
||||
|
||||
function isIgnorableGBlobObjectURLError(error) {
|
||||
const message = error == null ? "" : String(error && error.message ? error.message : error);
|
||||
return message.indexOf("gblob source revoked") >= 0 ||
|
||||
message.indexOf("gblob source not found") >= 0 ||
|
||||
message.indexOf("gblob source closed") >= 0 ||
|
||||
message.indexOf("gblob source aborted") >= 0;
|
||||
}
|
||||
|
||||
function getValueTypeName(value) {
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
if (value === undefined) {
|
||||
return "undefined";
|
||||
}
|
||||
if (value && value.constructor && typeof value.constructor.name === "string" && value.constructor.name) {
|
||||
return value.constructor.name;
|
||||
}
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function toReadableStreamReader(value, sourceLabel) {
|
||||
if (value instanceof ReadableStream) {
|
||||
return value.getReader();
|
||||
}
|
||||
throw new TypeError(sourceLabel + " must return a ReadableStream, got " + getValueTypeName(value));
|
||||
}
|
||||
|
||||
function describeObjectURLValue(value) {
|
||||
if (value && value.__gopeedStreamBackedBlob) {
|
||||
const stream = value.__gopeedBodyStream;
|
||||
value.__gopeedBodyStream = null;
|
||||
if (stream instanceof ReadableStream) {
|
||||
return {
|
||||
kind: "readable",
|
||||
initialReadable: stream,
|
||||
openReadable: null,
|
||||
sourceLabel: "URL.createObjectURL Response blob stream",
|
||||
};
|
||||
}
|
||||
}
|
||||
if (value instanceof Blob) {
|
||||
return {
|
||||
kind: "blob",
|
||||
value,
|
||||
};
|
||||
}
|
||||
if (value instanceof ReadableStream) {
|
||||
return {
|
||||
kind: "readable",
|
||||
initialReadable: value,
|
||||
openReadable: null,
|
||||
sourceLabel: "URL.createObjectURL ReadableStream",
|
||||
};
|
||||
}
|
||||
if (typeof value === "function") {
|
||||
return {
|
||||
kind: "opener",
|
||||
initialReadable: null,
|
||||
openReadable: value,
|
||||
sourceLabel: "URL.createObjectURL opener function",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "other",
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
function startReadableObjectURL(url, state) {
|
||||
activeReadableObjectURLs.set(url, state);
|
||||
setTimeout(() => {
|
||||
void pumpReadableObjectURL(url, state);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function releaseReader(reader, reason) {
|
||||
if (!reader) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (reason !== undefined && typeof reader.cancel === "function") {
|
||||
const result = reader.cancel(reason);
|
||||
if (result && typeof result.catch === "function") {
|
||||
result.catch(function () {});
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
if (typeof reader.releaseLock === "function") {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function releaseActiveReadable(url, reason) {
|
||||
const active = activeReadableObjectURLs.get(url);
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
activeReadableObjectURLs.delete(url);
|
||||
active.cancelled = true;
|
||||
releaseReader(active.reader, reason);
|
||||
}
|
||||
|
||||
async function pumpReadableObjectURL(url, state) {
|
||||
let reader = null;
|
||||
try {
|
||||
let source;
|
||||
if (state.initialReadable) {
|
||||
source = state.initialReadable;
|
||||
state.initialReadable = null;
|
||||
} else {
|
||||
if (typeof state.openReadable !== "function") {
|
||||
throw new Error("gblob readable stream is not reopenable");
|
||||
}
|
||||
source = await state.openReadable(state.offset);
|
||||
}
|
||||
reader = toReadableStreamReader(source, state.sourceLabel);
|
||||
if (activeReadableObjectURLs.get(url) !== state || state.cancelled) {
|
||||
releaseReader(reader, "stale gblob producer");
|
||||
reader = null;
|
||||
return;
|
||||
}
|
||||
state.reader = reader;
|
||||
let bytesSinceYield = 0;
|
||||
while (!state.cancelled) {
|
||||
const current = activeReadableObjectURLs.get(url);
|
||||
if (current !== state) {
|
||||
releaseReader(reader, "stale gblob producer");
|
||||
reader = null;
|
||||
return;
|
||||
}
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
if (activeReadableObjectURLs.get(url) === state) {
|
||||
activeReadableObjectURLs.delete(url);
|
||||
await closeWritableObjectURL(url);
|
||||
}
|
||||
releaseReader(reader);
|
||||
reader = null;
|
||||
return;
|
||||
}
|
||||
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value);
|
||||
if (activeReadableObjectURLs.get(url) !== state || state.cancelled) {
|
||||
releaseReader(reader, "stale gblob producer");
|
||||
reader = null;
|
||||
return;
|
||||
}
|
||||
await writeWritableObjectURL(url, chunk);
|
||||
bytesSinceYield += chunk.byteLength;
|
||||
if (bytesSinceYield >= readableObjectURLYieldBytes) {
|
||||
bytesSinceYield = 0;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
releaseReader(reader, "gblob producer cancelled");
|
||||
reader = null;
|
||||
} catch (error) {
|
||||
if (isIgnorableGBlobObjectURLError(error)) {
|
||||
if (activeReadableObjectURLs.get(url) === state) {
|
||||
activeReadableObjectURLs.delete(url);
|
||||
}
|
||||
if (reader) {
|
||||
releaseReader(reader, "gblob source closed");
|
||||
reader = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (activeReadableObjectURLs.get(url) === state) {
|
||||
activeReadableObjectURLs.delete(url);
|
||||
try {
|
||||
await abortWritableObjectURL(url, error == null ? "" : String(error && error.message ? error.message : error));
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
if (reader) {
|
||||
releaseReader(reader, error);
|
||||
reader = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.__gopeed_open_writable_stream_object_url = function (url, offset) {
|
||||
const openReadable = resumableReadableObjectURLs.get(url);
|
||||
if (typeof openReadable !== "function") {
|
||||
throw new Error("gblob resumable opener not found");
|
||||
}
|
||||
releaseActiveReadable(url, "gblob source reopened");
|
||||
startReadableObjectURL(url, {
|
||||
initialReadable: null,
|
||||
openReadable,
|
||||
sourceLabel: "URL.createObjectURL opener function",
|
||||
offset: Number(offset) || 0,
|
||||
reader: null,
|
||||
cancelled: false,
|
||||
});
|
||||
};
|
||||
|
||||
URL.createObjectURL = function (value) {
|
||||
if (value instanceof Blob) {
|
||||
return createBlobObjectURL(value._buffer, value.type || "");
|
||||
const described = describeObjectURLValue(value);
|
||||
if (described.kind === "blob") {
|
||||
return createBlobObjectURL(described.value._buffer, described.value.type || "");
|
||||
}
|
||||
if (value instanceof WritableStream) {
|
||||
if (value.__gopeedObjectURL) {
|
||||
return value.__gopeedObjectURL;
|
||||
}
|
||||
const url = createWritableObjectURL();
|
||||
value._addObserver({
|
||||
write(chunk) {
|
||||
return Promise.resolve(writeWritableObjectURL(url, chunk));
|
||||
},
|
||||
close() {
|
||||
return Promise.resolve(closeWritableObjectURL(url));
|
||||
},
|
||||
abort(reason) {
|
||||
return Promise.resolve(abortWritableObjectURL(url, reason == null ? "" : String(reason)));
|
||||
}
|
||||
if (described.kind === "readable") {
|
||||
const url = createWritableObjectURL(false);
|
||||
startReadableObjectURL(url, {
|
||||
initialReadable: described.initialReadable,
|
||||
openReadable: null,
|
||||
sourceLabel: described.sourceLabel,
|
||||
offset: 0,
|
||||
reader: null,
|
||||
cancelled: false,
|
||||
});
|
||||
value.__gopeedObjectURL = url;
|
||||
return url;
|
||||
}
|
||||
if (originalCreateObjectURL) {
|
||||
return originalCreateObjectURL(value);
|
||||
if (described.kind === "opener") {
|
||||
const url = createWritableObjectURL(true);
|
||||
resumableReadableObjectURLs.set(url, described.openReadable);
|
||||
return url;
|
||||
}
|
||||
throw new TypeError("Unsupported object type");
|
||||
throw new TypeError("Unsupported object type for URL.createObjectURL: " + getValueTypeName(described.value) + ". Expected Blob, ReadableStream, or opener function");
|
||||
};
|
||||
|
||||
URL.revokeObjectURL = function (url) {
|
||||
if (typeof url === "string" && url.indexOf("gblob:") === 0) {
|
||||
resumableReadableObjectURLs.delete(url);
|
||||
releaseActiveReadable(url, "gblob source revoked");
|
||||
revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
@@ -338,4 +792,83 @@
|
||||
originalRevokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
const originalFetch = typeof globalThis.fetch === "function"
|
||||
? globalThis.fetch.bind(globalThis)
|
||||
: null;
|
||||
|
||||
if (typeof fetchOpen === "function") {
|
||||
globalThis.fetch = async function (input, init) {
|
||||
const request = new Request(input, init);
|
||||
if (originalFetch && (request.method === "HEAD" || request.redirect === "manual" || request.redirect === "error")) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
let body = null;
|
||||
if (request._bodyFormData) {
|
||||
body = request._bodyFormData;
|
||||
} else if (request._bodyArrayBuffer) {
|
||||
body = request._bodyArrayBuffer;
|
||||
} else if (request._bodyBlob) {
|
||||
body = await request._bodyBlob.arrayBuffer();
|
||||
} else if (request._bodyInit != null && typeof request._bodyInit === "object") {
|
||||
body = request._bodyInit;
|
||||
} else if (request._bodyText != null) {
|
||||
body = request._bodyText;
|
||||
} else if (request._bodyInit != null) {
|
||||
body = request._bodyInit;
|
||||
}
|
||||
const headers = [];
|
||||
request.headers.forEach((value, key) => {
|
||||
headers.push([key, value]);
|
||||
});
|
||||
let meta;
|
||||
try {
|
||||
meta = fetchOpen({
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers,
|
||||
body,
|
||||
redirect: request.redirect,
|
||||
credentials: request.credentials
|
||||
});
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new TypeError(String(error));
|
||||
}
|
||||
const stream = new ReadableStream({
|
||||
pull(controller) {
|
||||
let chunk;
|
||||
try {
|
||||
chunk = fetchRead(meta.id, 64 * 1024);
|
||||
} catch (error) {
|
||||
fetchClose(meta.id);
|
||||
controller.error(error instanceof Error ? error : new TypeError(String(error)));
|
||||
return;
|
||||
}
|
||||
if (chunk == null) {
|
||||
fetchClose(meta.id);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
||||
if (bytes.byteLength === 0) {
|
||||
fetchClose(meta.id);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(bytes);
|
||||
},
|
||||
cancel(reason) {
|
||||
fetchAbort(meta.id, reason == null ? "" : String(reason));
|
||||
}
|
||||
});
|
||||
const response = new Response(null, {
|
||||
status: meta.status,
|
||||
statusText: meta.statusText,
|
||||
headers: meta.headers,
|
||||
url: meta.url
|
||||
});
|
||||
return attachResponseStreaming(response, stream);
|
||||
};
|
||||
globalThis.fetch.__gopeedOriginalFetch = originalFetch;
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja_nodejs/eventloop"
|
||||
)
|
||||
@@ -25,7 +27,12 @@ func (vm *Vm) Get(name string) (value any) {
|
||||
func (vm *Vm) RunString(script string) (value any, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = r.(error)
|
||||
switch v := r.(type) {
|
||||
case error:
|
||||
err = v
|
||||
default:
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+893
-2128
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,9 @@
|
||||
"webpack-cli": "^5.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"abortcontroller-polyfill": "^1.7.8",
|
||||
"blob-polyfill": "^7.0.20220408",
|
||||
"event-target-shim": "^6.0.2",
|
||||
"fastestsmallesttextencoderdecoder": "^1.0.22",
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import "../event/index.js";
|
||||
import "abortcontroller-polyfill/dist/abortcontroller-polyfill-only.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Event, EventTarget } from "event-target-shim";
|
||||
|
||||
if (typeof globalThis.Event !== "function") {
|
||||
globalThis.Event = Event;
|
||||
}
|
||||
|
||||
if (typeof globalThis.EventTarget !== "function") {
|
||||
globalThis.EventTarget = EventTarget;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import "./event/index.js"
|
||||
import "./abort/index.js"
|
||||
import "./blob/index.js"
|
||||
import "./crypto/index.js"
|
||||
// polyfill TextEncoder
|
||||
import 'fastestsmallesttextencoderdecoder';
|
||||
import "./fetch/index.js"
|
||||
import "./fetch/index.js"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package webview
|
||||
|
||||
type Provider interface {
|
||||
IsAvailable() bool
|
||||
Open(opts OpenOptions) (Page, error)
|
||||
}
|
||||
|
||||
type unavailableProvider struct{}
|
||||
|
||||
func NewUnavailableProvider() Provider {
|
||||
return unavailableProvider{}
|
||||
}
|
||||
|
||||
func (unavailableProvider) IsAvailable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (unavailableProvider) Open(OpenOptions) (Page, error) {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package webview
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const RPCEndpointPath = "/webview"
|
||||
|
||||
const (
|
||||
MethodIsAvailable = "webview.isAvailable"
|
||||
MethodPageOpen = "page.open"
|
||||
MethodPageAddInitScript = "page.addInitScript"
|
||||
MethodPageGoto = "page.goto"
|
||||
MethodPageExecute = "page.execute"
|
||||
MethodPageGetCookies = "page.getCookies"
|
||||
MethodPageSetCookie = "page.setCookie"
|
||||
MethodPageDeleteCookie = "page.deleteCookie"
|
||||
MethodPageClearCookies = "page.clearCookies"
|
||||
MethodPageClose = "page.close"
|
||||
)
|
||||
|
||||
const (
|
||||
ErrorCodeInvalidRequest = "INVALID_REQUEST"
|
||||
ErrorCodeUnknownMethod = "UNKNOWN_METHOD"
|
||||
ErrorCodeUnavailable = "UNAVAILABLE"
|
||||
ErrorCodeBrowserNotFound = "BROWSER_NOT_FOUND"
|
||||
ErrorCodePageNotFound = "PAGE_NOT_FOUND"
|
||||
ErrorCodeNavigationFailed = "NAVIGATION_FAILED"
|
||||
ErrorCodeEvaluationFailed = "EVALUATION_FAILED"
|
||||
ErrorCodeTimeout = "TIMEOUT"
|
||||
ErrorCodeInternal = "INTERNAL_ERROR"
|
||||
)
|
||||
|
||||
type RPCConfig struct {
|
||||
Network string `json:"network"`
|
||||
Address string `json:"address"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
func (c RPCConfig) Enabled() bool {
|
||||
return c.Network != "" && c.Address != ""
|
||||
}
|
||||
|
||||
type RPCRequest struct {
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params"`
|
||||
}
|
||||
|
||||
type RPCResponse struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *RPCError `json:"error"`
|
||||
}
|
||||
|
||||
type RPCError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *RPCError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
type IsAvailableParams struct{}
|
||||
|
||||
type IsAvailableResult struct {
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
type PageOpenParams struct {
|
||||
Headless bool `json:"headless,omitempty"`
|
||||
Debug bool `json:"debug,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
}
|
||||
|
||||
func NewPageOpenParams(opts OpenOptions) PageOpenParams {
|
||||
return PageOpenParams{
|
||||
Headless: opts.Headless,
|
||||
Debug: opts.Debug,
|
||||
Title: opts.Title,
|
||||
Width: opts.Width,
|
||||
Height: opts.Height,
|
||||
UserAgent: opts.UserAgent,
|
||||
}
|
||||
}
|
||||
|
||||
type PageOpenResult struct {
|
||||
PageID string `json:"pageId"`
|
||||
}
|
||||
|
||||
type PageAddInitScriptParams struct {
|
||||
PageID string `json:"pageId"`
|
||||
Script string `json:"script"`
|
||||
}
|
||||
|
||||
type PageGotoParams struct {
|
||||
PageID string `json:"pageId"`
|
||||
URL string `json:"url"`
|
||||
TimeoutMS int64 `json:"timeoutMs,omitempty"`
|
||||
WaitUntil string `json:"waitUntil,omitempty"`
|
||||
}
|
||||
|
||||
func NewPageGotoParams(pageID string, url string, opts GotoOptions) PageGotoParams {
|
||||
return PageGotoParams{
|
||||
PageID: pageID,
|
||||
URL: url,
|
||||
TimeoutMS: opts.TimeoutMS,
|
||||
WaitUntil: opts.WaitUntil,
|
||||
}
|
||||
}
|
||||
|
||||
type PageExecuteParams struct {
|
||||
PageID string `json:"pageId"`
|
||||
Expression string `json:"expression"`
|
||||
Args []any `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
type PageGetCookiesParams struct {
|
||||
PageID string `json:"pageId"`
|
||||
}
|
||||
|
||||
type PageSetCookieParams struct {
|
||||
PageID string `json:"pageId"`
|
||||
Cookie Cookie `json:"cookie"`
|
||||
}
|
||||
|
||||
type PageDeleteCookieParams struct {
|
||||
PageID string `json:"pageId"`
|
||||
Cookie Cookie `json:"cookie"`
|
||||
}
|
||||
|
||||
type PageClearCookiesParams struct {
|
||||
PageID string `json:"pageId"`
|
||||
}
|
||||
|
||||
type PageCloseParams struct {
|
||||
PageID string `json:"pageId"`
|
||||
}
|
||||
|
||||
type EmptyResult struct{}
|
||||
@@ -0,0 +1,765 @@
|
||||
package webview
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
var ErrUnavailable = errors.New("gopeed runtime webview is unavailable")
|
||||
|
||||
type Opener interface {
|
||||
Open(opts OpenOptions) (Page, error)
|
||||
}
|
||||
|
||||
type Page interface {
|
||||
AddInitScript(script string) error
|
||||
Goto(url string, opts GotoOptions) error
|
||||
Execute(expression string, args ...any) (any, error)
|
||||
GetCookies() ([]Cookie, error)
|
||||
SetCookie(cookie Cookie) error
|
||||
DeleteCookie(cookie Cookie) error
|
||||
ClearCookies() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type OpenOptions struct {
|
||||
Headless bool
|
||||
Debug bool
|
||||
Title string
|
||||
Width int
|
||||
Height int
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type GotoOptions struct {
|
||||
TimeoutMS int64
|
||||
WaitUntil string
|
||||
}
|
||||
|
||||
type ClickOptions struct {
|
||||
DelayMS int64
|
||||
}
|
||||
|
||||
type TypeOptions struct {
|
||||
DelayMS int64
|
||||
}
|
||||
|
||||
type WaitOptions struct {
|
||||
TimeoutMS int64
|
||||
PollIntervalMS int64
|
||||
}
|
||||
|
||||
type WaitForSelectorOptions struct {
|
||||
TimeoutMS int64
|
||||
PollIntervalMS int64
|
||||
Visible bool
|
||||
Hidden bool
|
||||
}
|
||||
|
||||
type Cookie struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Expires time.Time `json:"expires,omitempty,omitzero"`
|
||||
Secure bool `json:"secure,omitempty"`
|
||||
HTTPOnly bool `json:"httpOnly,omitempty"`
|
||||
}
|
||||
|
||||
type Runtime struct {
|
||||
opener Opener
|
||||
available bool
|
||||
|
||||
mu sync.Mutex
|
||||
pageSeq uint64
|
||||
pages map[string]Page
|
||||
}
|
||||
|
||||
func NewRuntime(opener Opener, available bool) *Runtime {
|
||||
if opener == nil {
|
||||
available = false
|
||||
}
|
||||
return &Runtime{
|
||||
opener: opener,
|
||||
available: available,
|
||||
pages: make(map[string]Page),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runtime) IsAvailable() bool {
|
||||
return r != nil && r.available && r.opener != nil
|
||||
}
|
||||
|
||||
func (r *Runtime) Open(opts ...map[string]any) (*PageHandle, error) {
|
||||
if !r.IsAvailable() {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
page, err := r.opener.Open(parseOpenOptions(firstMap(opts)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.pageSeq++
|
||||
id := fmt.Sprintf("page-%d", r.pageSeq)
|
||||
r.pages[id] = page
|
||||
return &PageHandle{runtime: r, id: id}, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) Close() error {
|
||||
r.mu.Lock()
|
||||
pageIDs := make([]string, 0, len(r.pages))
|
||||
for id := range r.pages {
|
||||
pageIDs = append(pageIDs, id)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
var errs []error
|
||||
for _, id := range pageIDs {
|
||||
if err := r.closePage(id); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
type PageHandle struct {
|
||||
runtime *Runtime
|
||||
id string
|
||||
}
|
||||
|
||||
func (p *PageHandle) AddInitScript(script string) error {
|
||||
page, err := p.page()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return page.AddInitScript(script)
|
||||
}
|
||||
|
||||
func (p *PageHandle) Goto(url string, opts ...map[string]any) error {
|
||||
page, err := p.page()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gotoOpts, err := parseGotoOptions(firstMap(opts))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return page.Goto(url, gotoOpts)
|
||||
}
|
||||
|
||||
func (p *PageHandle) Execute(scriptOrFn any, args ...any) (any, error) {
|
||||
page, err := p.page()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expression, err := normalizeExecutable(scriptOrFn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return page.Execute(expression, args...)
|
||||
}
|
||||
|
||||
func (p *PageHandle) Focus(selector string) error {
|
||||
_, err := p.Execute(`(selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) {
|
||||
throw new Error("element not found: " + selector);
|
||||
}
|
||||
if (typeof element.focus !== "function") {
|
||||
throw new Error("element is not focusable: " + selector);
|
||||
}
|
||||
element.focus();
|
||||
return true;
|
||||
}`, selector)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *PageHandle) Click(selector string, opts ...map[string]any) error {
|
||||
clickOpts := parseClickOptions(firstMap(opts))
|
||||
_, err := p.Execute(`(selector, delayMS) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) {
|
||||
throw new Error("element not found: " + selector);
|
||||
}
|
||||
if (typeof element.scrollIntoView === "function") {
|
||||
element.scrollIntoView({ block: "center", inline: "center" });
|
||||
}
|
||||
const performClick = () => {
|
||||
if (typeof element.click === "function") {
|
||||
element.click();
|
||||
return;
|
||||
}
|
||||
element.dispatchEvent(new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
}));
|
||||
};
|
||||
if (delayMS > 0) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
performClick();
|
||||
resolve(true);
|
||||
}, delayMS);
|
||||
});
|
||||
}
|
||||
performClick();
|
||||
return true;
|
||||
}`, selector, clickOpts.DelayMS)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *PageHandle) Type(selector string, text string, opts ...map[string]any) error {
|
||||
typeOpts := parseTypeOptions(firstMap(opts))
|
||||
_, err := p.Execute(`(selector, text, delayMS) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) {
|
||||
throw new Error("element not found: " + selector);
|
||||
}
|
||||
if (typeof element.focus === "function") {
|
||||
element.focus();
|
||||
}
|
||||
const emitInput = () => {
|
||||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
};
|
||||
const insertIntoInput = (chunk) => {
|
||||
if (element.disabled) {
|
||||
throw new Error("element is disabled: " + selector);
|
||||
}
|
||||
if (element.readOnly) {
|
||||
throw new Error("element is readonly: " + selector);
|
||||
}
|
||||
const value = typeof element.value === "string" ? element.value : "";
|
||||
const hasSelection = typeof element.selectionStart === "number" && typeof element.selectionEnd === "number";
|
||||
const start = hasSelection ? element.selectionStart : value.length;
|
||||
const end = hasSelection ? element.selectionEnd : value.length;
|
||||
element.value = value.slice(0, start) + chunk + value.slice(end);
|
||||
const caret = start + chunk.length;
|
||||
if (typeof element.setSelectionRange === "function") {
|
||||
element.setSelectionRange(caret, caret);
|
||||
}
|
||||
emitInput();
|
||||
};
|
||||
const insertIntoEditable = (chunk) => {
|
||||
if (element.isContentEditable) {
|
||||
element.textContent = (element.textContent || "") + chunk;
|
||||
emitInput();
|
||||
return;
|
||||
}
|
||||
insertIntoInput(chunk);
|
||||
};
|
||||
const insertChunk = (chunk) => {
|
||||
const tagName = (element.tagName || "").toUpperCase();
|
||||
if (tagName === "INPUT" || tagName === "TEXTAREA" || typeof element.value === "string") {
|
||||
insertIntoInput(chunk);
|
||||
return;
|
||||
}
|
||||
if (element.isContentEditable) {
|
||||
insertIntoEditable(chunk);
|
||||
return;
|
||||
}
|
||||
throw new Error("element is not typable: " + selector);
|
||||
};
|
||||
const chars = Array.from(String(text));
|
||||
if (delayMS > 0) {
|
||||
return new Promise((resolve) => {
|
||||
let index = 0;
|
||||
const step = () => {
|
||||
if (index >= chars.length) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
insertChunk(chars[index]);
|
||||
index += 1;
|
||||
setTimeout(step, delayMS);
|
||||
};
|
||||
step();
|
||||
});
|
||||
}
|
||||
for (const char of chars) {
|
||||
insertChunk(char);
|
||||
}
|
||||
return true;
|
||||
}`, selector, text, typeOpts.DelayMS)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *PageHandle) WaitForSelector(selector string, opts ...map[string]any) (bool, error) {
|
||||
waitOpts := parseWaitForSelectorOptions(firstMap(opts))
|
||||
_, matched, err := p.poll(WaitOptions{
|
||||
TimeoutMS: waitOpts.TimeoutMS,
|
||||
PollIntervalMS: waitOpts.PollIntervalMS,
|
||||
}, func() (any, bool, error) {
|
||||
value, err := p.Execute(`(selector, visible, hidden) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (hidden) {
|
||||
if (!element) return true;
|
||||
const style = window.getComputedStyle(element);
|
||||
if (!style) return !!element.hidden;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return element.hidden || style.display === "none" || style.visibility === "hidden" || rect.width === 0 || rect.height === 0;
|
||||
}
|
||||
if (!element) return false;
|
||||
if (!visible) return true;
|
||||
const style = window.getComputedStyle(element);
|
||||
if (!style) return true;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return !element.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
}`, selector, waitOpts.Visible, waitOpts.Hidden)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return true, truthy(value), nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func (p *PageHandle) WaitForFunction(scriptOrFn any, args ...any) (any, error) {
|
||||
expression, err := normalizeExecutable(scriptOrFn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
waitOpts := WaitOptions{}
|
||||
callArgs := args
|
||||
if len(args) > 0 {
|
||||
if raw, ok := args[0].(map[string]any); ok {
|
||||
waitOpts = parseWaitOptions(raw)
|
||||
callArgs = args[1:]
|
||||
}
|
||||
}
|
||||
|
||||
value, matched, err := p.poll(waitOpts, func() (any, bool, error) {
|
||||
result, err := p.Execute(`(expression, args) => {
|
||||
const target = (0, eval)(expression);
|
||||
return Promise.resolve(typeof target === "function" ? target(...args) : target).then((value) => ({
|
||||
matched: !!value,
|
||||
value: value ?? null,
|
||||
}));
|
||||
}`, expression, callArgs)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
payload, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("unexpected waitForFunction payload: %T", result)
|
||||
}
|
||||
return payload["value"], truthy(payload["matched"]), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !matched {
|
||||
return nil, nil
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (p *PageHandle) GetCookies() ([]Cookie, error) {
|
||||
page, err := p.page()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return page.GetCookies()
|
||||
}
|
||||
|
||||
func (p *PageHandle) SetCookie(raw map[string]any) error {
|
||||
page, err := p.page()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return page.SetCookie(parseCookie(raw))
|
||||
}
|
||||
|
||||
func (p *PageHandle) DeleteCookie(raw map[string]any) error {
|
||||
page, err := p.page()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return page.DeleteCookie(parseCookie(raw))
|
||||
}
|
||||
|
||||
func (p *PageHandle) ClearCookies() error {
|
||||
page, err := p.page()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return page.ClearCookies()
|
||||
}
|
||||
|
||||
func (p *PageHandle) URL() (string, error) {
|
||||
value, err := p.Execute(`() => location.href`)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseString(value), nil
|
||||
}
|
||||
|
||||
func (p *PageHandle) Content() (string, error) {
|
||||
value, err := p.Execute(`() => document.documentElement ? document.documentElement.outerHTML : ""`)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseString(value), nil
|
||||
}
|
||||
|
||||
func (p *PageHandle) Close() error {
|
||||
if p == nil || p.runtime == nil {
|
||||
return nil
|
||||
}
|
||||
return p.runtime.closePage(p.id)
|
||||
}
|
||||
|
||||
func (p *PageHandle) page() (Page, error) {
|
||||
if p == nil || p.runtime == nil {
|
||||
return nil, fmt.Errorf("webview page handle is not initialized")
|
||||
}
|
||||
return p.runtime.getPage(p.id)
|
||||
}
|
||||
|
||||
func (p *PageHandle) poll(opts WaitOptions, fn func() (any, bool, error)) (any, bool, error) {
|
||||
timeout := time.Duration(defaultWaitTimeoutMS(opts.TimeoutMS)) * time.Millisecond
|
||||
pollInterval := time.Duration(defaultPollIntervalMS(opts.PollIntervalMS)) * time.Millisecond
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
value, matched, err := fn()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if matched {
|
||||
return value, true, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, false, nil
|
||||
}
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runtime) getPage(id string) (Page, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
page, ok := r.pages[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("page not found")
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) closePage(id string) error {
|
||||
r.mu.Lock()
|
||||
page, ok := r.pages[id]
|
||||
if !ok {
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
delete(r.pages, id)
|
||||
r.mu.Unlock()
|
||||
return page.Close()
|
||||
}
|
||||
|
||||
func parseOpenOptions(raw map[string]any) OpenOptions {
|
||||
return OpenOptions{
|
||||
Headless: parseBool(raw["headless"]),
|
||||
Debug: parseBool(raw["debug"]),
|
||||
Title: parseString(raw["title"]),
|
||||
Width: parseInt(raw["width"]),
|
||||
Height: parseInt(raw["height"]),
|
||||
UserAgent: parseString(raw["userAgent"]),
|
||||
}
|
||||
}
|
||||
|
||||
func parseGotoOptions(raw map[string]any) (GotoOptions, error) {
|
||||
waitUntil, err := normalizeWaitUntil(parseString(raw["waitUntil"]))
|
||||
if err != nil {
|
||||
return GotoOptions{}, err
|
||||
}
|
||||
return GotoOptions{
|
||||
TimeoutMS: parseInt64(raw["timeoutMs"]),
|
||||
WaitUntil: waitUntil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeWaitUntil(raw string) (string, error) {
|
||||
if raw == "" {
|
||||
return "load", nil
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "load":
|
||||
return "load", nil
|
||||
case "domcontentloaded":
|
||||
return "domcontentloaded", nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid waitUntil: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func parseClickOptions(raw map[string]any) ClickOptions {
|
||||
return ClickOptions{DelayMS: parseInt64(raw["delay"])}
|
||||
}
|
||||
|
||||
func parseTypeOptions(raw map[string]any) TypeOptions {
|
||||
return TypeOptions{DelayMS: parseInt64(raw["delay"])}
|
||||
}
|
||||
|
||||
func parseWaitOptions(raw map[string]any) WaitOptions {
|
||||
return WaitOptions{
|
||||
TimeoutMS: parseInt64(raw["timeoutMs"]),
|
||||
PollIntervalMS: parseInt64(raw["pollIntervalMs"]),
|
||||
}
|
||||
}
|
||||
|
||||
func parseWaitForSelectorOptions(raw map[string]any) WaitForSelectorOptions {
|
||||
return WaitForSelectorOptions{
|
||||
TimeoutMS: parseInt64(raw["timeoutMs"]),
|
||||
PollIntervalMS: parseInt64(raw["pollIntervalMs"]),
|
||||
Visible: parseBool(raw["visible"]),
|
||||
Hidden: parseBool(raw["hidden"]),
|
||||
}
|
||||
}
|
||||
|
||||
func parseCookie(raw map[string]any) Cookie {
|
||||
cookie := Cookie{
|
||||
Name: parseString(raw["name"]),
|
||||
Value: parseString(raw["value"]),
|
||||
Domain: parseString(raw["domain"]),
|
||||
Path: parseString(raw["path"]),
|
||||
Secure: parseBool(raw["secure"]),
|
||||
HTTPOnly: parseBool(raw["httpOnly"]),
|
||||
}
|
||||
if rawExpires, ok := raw["expires"]; ok {
|
||||
cookie.Expires = parseTime(rawExpires)
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
func defaultWaitTimeoutMS(value int64) int64 {
|
||||
if value <= 0 {
|
||||
return 10000
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func defaultPollIntervalMS(value int64) int64 {
|
||||
if value <= 0 {
|
||||
return 100
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeExecutable(scriptOrFn any) (string, error) {
|
||||
switch value := scriptOrFn.(type) {
|
||||
case string:
|
||||
return value, nil
|
||||
case *goja.Object:
|
||||
return NormalizeExecutableValue(value)
|
||||
default:
|
||||
return "", fmt.Errorf("execute expects a string or function, got %T", scriptOrFn)
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeExecutableValue(value goja.Value) (string, error) {
|
||||
if value == nil {
|
||||
return "", fmt.Errorf("execute expects a string or function")
|
||||
}
|
||||
switch raw := value.Export().(type) {
|
||||
case string:
|
||||
return raw, nil
|
||||
}
|
||||
obj, ok := value.(*goja.Object)
|
||||
if ok {
|
||||
if _, ok := goja.AssertFunction(obj); ok {
|
||||
source, err := functionSource(obj)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return normalizeFunctionSource(source), nil
|
||||
}
|
||||
}
|
||||
source := strings.TrimSpace(value.String())
|
||||
if looksLikeFunctionSource(source) {
|
||||
return normalizeFunctionSource(source), nil
|
||||
}
|
||||
return "", fmt.Errorf("execute expects a string or function, got %T (%q)", value, source)
|
||||
}
|
||||
|
||||
func normalizeFunctionSource(source string) string {
|
||||
repaired := repairArrowObjectLiteralSource(source)
|
||||
return "(" + repaired + ")"
|
||||
}
|
||||
|
||||
func functionSource(fn *goja.Object) (string, error) {
|
||||
toStringValue := fn.Get("toString")
|
||||
toString, ok := goja.AssertFunction(toStringValue)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("function.toString is not callable")
|
||||
}
|
||||
value, err := toString(fn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value.String(), nil
|
||||
}
|
||||
|
||||
func repairArrowObjectLiteralSource(source string) string {
|
||||
trimmed := strings.TrimSpace(source)
|
||||
if !strings.Contains(trimmed, "=>") || !strings.Contains(trimmed, "({") {
|
||||
return source
|
||||
}
|
||||
if !strings.HasSuffix(trimmed, "}") {
|
||||
return source
|
||||
}
|
||||
if strings.Count(trimmed, "(")-strings.Count(trimmed, ")") != 1 {
|
||||
return source
|
||||
}
|
||||
if strings.Count(trimmed, "{") != strings.Count(trimmed, "}") {
|
||||
return source
|
||||
}
|
||||
// TODO: Remove this narrow repair once goja fixes toString() for concise arrow
|
||||
// functions that directly return object literals, then upstream the fix as a PR.
|
||||
return source + ")"
|
||||
}
|
||||
|
||||
func looksLikeFunctionSource(source string) bool {
|
||||
if source == "" || source == "[object Object]" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(source, "=>") ||
|
||||
strings.HasPrefix(source, "function") ||
|
||||
strings.HasPrefix(source, "async function") ||
|
||||
strings.HasPrefix(source, "async (") ||
|
||||
strings.HasPrefix(source, "async(")
|
||||
}
|
||||
|
||||
func truthy(value any) bool {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return v != ""
|
||||
case int:
|
||||
return v != 0
|
||||
case int8:
|
||||
return v != 0
|
||||
case int16:
|
||||
return v != 0
|
||||
case int32:
|
||||
return v != 0
|
||||
case int64:
|
||||
return v != 0
|
||||
case uint:
|
||||
return v != 0
|
||||
case uint8:
|
||||
return v != 0
|
||||
case uint16:
|
||||
return v != 0
|
||||
case uint32:
|
||||
return v != 0
|
||||
case uint64:
|
||||
return v != 0
|
||||
case float32:
|
||||
return v != 0
|
||||
case float64:
|
||||
return v != 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func firstMap(values []map[string]any) map[string]any {
|
||||
if len(values) == 0 || values[0] == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
func parseString(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
|
||||
func parseBool(value any) bool {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
parsed, _ := strconv.ParseBool(v)
|
||||
return parsed
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt(value any) int {
|
||||
return int(parseInt64(value))
|
||||
}
|
||||
|
||||
func parseInt64(value any) int64 {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return int64(v)
|
||||
case int8:
|
||||
return int64(v)
|
||||
case int16:
|
||||
return int64(v)
|
||||
case int32:
|
||||
return int64(v)
|
||||
case int64:
|
||||
return v
|
||||
case uint:
|
||||
return int64(v)
|
||||
case uint8:
|
||||
return int64(v)
|
||||
case uint16:
|
||||
return int64(v)
|
||||
case uint32:
|
||||
return int64(v)
|
||||
case uint64:
|
||||
return int64(v)
|
||||
case float32:
|
||||
return int64(v)
|
||||
case float64:
|
||||
return int64(v)
|
||||
case string:
|
||||
parsed, _ := strconv.ParseInt(v, 10, 64)
|
||||
return parsed
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func parseTime(value any) time.Time {
|
||||
switch v := value.(type) {
|
||||
case time.Time:
|
||||
return v
|
||||
case int64:
|
||||
return time.UnixMilli(v)
|
||||
case int:
|
||||
return time.UnixMilli(int64(v))
|
||||
case float64:
|
||||
return time.UnixMilli(int64(v))
|
||||
case string:
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, v); err == nil {
|
||||
return parsed
|
||||
}
|
||||
if ms, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
return time.UnixMilli(ms)
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package webview
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestRuntimeOpenAndExecuteFunctionSource(t *testing.T) {
|
||||
opener := &fakeOpener{
|
||||
page: &fakePage{executeValue: "ok"},
|
||||
}
|
||||
runtime := NewRuntime(opener, true)
|
||||
page, err := runtime.Open(map[string]any{
|
||||
"headless": true,
|
||||
"debug": true,
|
||||
"title": "Gopeed",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"userAgent": "UA",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := goja.New()
|
||||
fnValue, err := vm.RunString(`(a, b) => a + b`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := page.Execute(fnValue, int64(1), int64(2))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != "ok" {
|
||||
t.Fatalf("unexpected execute result: %#v", value)
|
||||
}
|
||||
if opener.opts.Title != "Gopeed" || opener.opts.UserAgent != "UA" {
|
||||
t.Fatalf("unexpected open options: %+v", opener.opts)
|
||||
}
|
||||
if opener.page.lastExecuteSource != "((a, b) => a + b)" {
|
||||
t.Fatalf("unexpected function source: %q", opener.page.lastExecuteSource)
|
||||
}
|
||||
if len(opener.page.lastExecuteArgs) != 2 || opener.page.lastExecuteArgs[0] != int64(1) || opener.page.lastExecuteArgs[1] != int64(2) {
|
||||
t.Fatalf("unexpected execute args: %#v", opener.page.lastExecuteArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeExecuteRepairsObjectLiteralArrowFunction(t *testing.T) {
|
||||
opener := &fakeOpener{
|
||||
page: &fakePage{executeValue: map[string]any{"ok": true}},
|
||||
}
|
||||
runtime := NewRuntime(opener, true)
|
||||
page, err := runtime.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := goja.New()
|
||||
fnValue, err := vm.RunString(`() => ({ ok: true })`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := page.Execute(fnValue); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opener.page.lastExecuteSource != "(() => ({ ok: true }))" {
|
||||
t.Fatalf("expected repaired function source, got %q", opener.page.lastExecuteSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeExecuteRepairsMultilineObjectLiteralArrowFunction(t *testing.T) {
|
||||
opener := &fakeOpener{
|
||||
page: &fakePage{executeValue: map[string]any{"ok": true}},
|
||||
}
|
||||
runtime := NewRuntime(opener, true)
|
||||
page, err := runtime.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := goja.New()
|
||||
fnValue, err := vm.RunString(`() => ({
|
||||
title: document.title || "",
|
||||
url: String(location.href || ""),
|
||||
userAgent: navigator.userAgent,
|
||||
readyState: document.readyState,
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := page.Execute(fnValue); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := `(() => ({
|
||||
title: document.title || "",
|
||||
url: String(location.href || ""),
|
||||
userAgent: navigator.userAgent,
|
||||
readyState: document.readyState,
|
||||
}))`
|
||||
if opener.page.lastExecuteSource != expected {
|
||||
t.Fatalf("expected repaired multiline function source, got %q", opener.page.lastExecuteSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeHelpers(t *testing.T) {
|
||||
opener := &fakeOpener{
|
||||
page: &fakePage{
|
||||
executeQueue: []any{
|
||||
false,
|
||||
true,
|
||||
map[string]any{"matched": false, "value": nil},
|
||||
map[string]any{"matched": true, "value": "ready"},
|
||||
"https://example.com",
|
||||
"<html></html>",
|
||||
},
|
||||
cookies: []Cookie{
|
||||
{Name: "a", Value: "1", Domain: "example.com", Path: "/"},
|
||||
{Name: "b", Value: "two", Domain: "example.com", Path: "/"},
|
||||
},
|
||||
},
|
||||
}
|
||||
runtime := NewRuntime(opener, true)
|
||||
page, err := runtime.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := page.AddInitScript("window.__TEST__ = true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opener.page.lastInitScript != "window.__TEST__ = true" {
|
||||
t.Fatalf("unexpected init script: %q", opener.page.lastInitScript)
|
||||
}
|
||||
if err := page.Goto("https://example.com", map[string]any{"waitUntil": "domcontentloaded"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opener.page.lastGotoURL != "https://example.com" {
|
||||
t.Fatalf("unexpected goto url: %q", opener.page.lastGotoURL)
|
||||
}
|
||||
if opener.page.lastGotoOpts.WaitUntil != "domcontentloaded" {
|
||||
t.Fatalf("unexpected goto waitUntil: %q", opener.page.lastGotoOpts.WaitUntil)
|
||||
}
|
||||
found, err := page.WaitForSelector("#app", map[string]any{"timeoutMs": 50, "pollIntervalMs": 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected selector to be found")
|
||||
}
|
||||
value, err := page.WaitForFunction(`() => window.__READY__`, map[string]any{"timeoutMs": 50, "pollIntervalMs": 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != "ready" {
|
||||
t.Fatalf("unexpected waitForFunction value: %#v", value)
|
||||
}
|
||||
cookies, err := page.GetCookies()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cookies) != 2 || cookies[0].Name != "a" || cookies[0].Value != "1" || cookies[1].Name != "b" || cookies[1].Value != "two" {
|
||||
t.Fatalf("unexpected cookies: %#v", cookies)
|
||||
}
|
||||
if err := page.SetCookie(map[string]any{
|
||||
"name": "session",
|
||||
"value": "abc",
|
||||
"domain": "example.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opener.page.lastSetCookie.Name != "session" || !opener.page.lastSetCookie.Secure || !opener.page.lastSetCookie.HTTPOnly {
|
||||
t.Fatalf("unexpected set cookie: %#v", opener.page.lastSetCookie)
|
||||
}
|
||||
if err := page.DeleteCookie(map[string]any{
|
||||
"name": "session",
|
||||
"domain": "example.com",
|
||||
"path": "/",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opener.page.lastDeleteCookie.Name != "session" || opener.page.lastDeleteCookie.Domain != "example.com" {
|
||||
t.Fatalf("unexpected delete cookie: %#v", opener.page.lastDeleteCookie)
|
||||
}
|
||||
if err := page.ClearCookies(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !opener.page.cookiesCleared {
|
||||
t.Fatal("expected cookies to be cleared")
|
||||
}
|
||||
url, err := page.URL()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if url != "https://example.com" {
|
||||
t.Fatalf("unexpected url: %q", url)
|
||||
}
|
||||
content, err := page.Content()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != "<html></html>" {
|
||||
t.Fatalf("unexpected content: %q", content)
|
||||
}
|
||||
if err := page.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !opener.page.closed {
|
||||
t.Fatal("expected page to be closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeInteractionHelpers(t *testing.T) {
|
||||
opener := &fakeOpener{
|
||||
page: &fakePage{executeValue: true},
|
||||
}
|
||||
runtime := NewRuntime(opener, true)
|
||||
page, err := runtime.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := page.Focus("#search"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(opener.page.lastExecuteArgs) != 1 || opener.page.lastExecuteArgs[0] != "#search" {
|
||||
t.Fatalf("unexpected focus args: %#v", opener.page.lastExecuteArgs)
|
||||
}
|
||||
if !strings.Contains(opener.page.lastExecuteSource, "element.focus()") {
|
||||
t.Fatalf("unexpected focus source: %q", opener.page.lastExecuteSource)
|
||||
}
|
||||
|
||||
if err := page.Click("#submit", map[string]any{"delay": 12}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(opener.page.lastExecuteArgs) != 2 || opener.page.lastExecuteArgs[0] != "#submit" || opener.page.lastExecuteArgs[1] != int64(12) {
|
||||
t.Fatalf("unexpected click args: %#v", opener.page.lastExecuteArgs)
|
||||
}
|
||||
if !strings.Contains(opener.page.lastExecuteSource, "performClick") {
|
||||
t.Fatalf("unexpected click source: %q", opener.page.lastExecuteSource)
|
||||
}
|
||||
|
||||
if err := page.Type("#search", "Gopeed", map[string]any{"delay": 5}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(opener.page.lastExecuteArgs) != 3 || opener.page.lastExecuteArgs[0] != "#search" || opener.page.lastExecuteArgs[1] != "Gopeed" || opener.page.lastExecuteArgs[2] != int64(5) {
|
||||
t.Fatalf("unexpected type args: %#v", opener.page.lastExecuteArgs)
|
||||
}
|
||||
if !strings.Contains(opener.page.lastExecuteSource, "element is not typable") {
|
||||
t.Fatalf("unexpected type source: %q", opener.page.lastExecuteSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeGotoRejectsInvalidWaitUntil(t *testing.T) {
|
||||
opener := &fakeOpener{
|
||||
page: &fakePage{},
|
||||
}
|
||||
runtime := NewRuntime(opener, true)
|
||||
page, err := runtime.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = page.Goto("https://example.com", map[string]any{"waitUntil": "networkidle"})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid waitUntil") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeOpener struct {
|
||||
opts OpenOptions
|
||||
page *fakePage
|
||||
}
|
||||
|
||||
func (o *fakeOpener) Open(opts OpenOptions) (Page, error) {
|
||||
o.opts = opts
|
||||
return o.page, nil
|
||||
}
|
||||
|
||||
type fakePage struct {
|
||||
lastInitScript string
|
||||
lastGotoURL string
|
||||
lastGotoOpts GotoOptions
|
||||
lastExecuteSource string
|
||||
lastExecuteArgs []any
|
||||
executeValue any
|
||||
executeQueue []any
|
||||
cookies []Cookie
|
||||
lastSetCookie Cookie
|
||||
lastDeleteCookie Cookie
|
||||
cookiesCleared bool
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (p *fakePage) AddInitScript(script string) error {
|
||||
p.lastInitScript = script
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *fakePage) Goto(url string, opts GotoOptions) error {
|
||||
p.lastGotoURL = url
|
||||
p.lastGotoOpts = opts
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *fakePage) Execute(expression string, args ...any) (any, error) {
|
||||
p.lastExecuteSource = expression
|
||||
p.lastExecuteArgs = args
|
||||
if len(p.executeQueue) > 0 {
|
||||
value := p.executeQueue[0]
|
||||
p.executeQueue = p.executeQueue[1:]
|
||||
return value, nil
|
||||
}
|
||||
return p.executeValue, nil
|
||||
}
|
||||
|
||||
func (p *fakePage) GetCookies() ([]Cookie, error) {
|
||||
return append([]Cookie(nil), p.cookies...), nil
|
||||
}
|
||||
|
||||
func (p *fakePage) SetCookie(cookie Cookie) error {
|
||||
p.lastSetCookie = cookie
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *fakePage) DeleteCookie(cookie Cookie) error {
|
||||
p.lastDeleteCookie = cookie
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *fakePage) ClearCookies() error {
|
||||
p.cookiesCleared = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *fakePage) Close() error {
|
||||
p.closed = true
|
||||
return nil
|
||||
}
|
||||
+36
-12
@@ -13,8 +13,10 @@ import (
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/logger"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
||||
gojautil "github.com/GopeedLab/gopeed/pkg/download/engine/util"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/go-git/go-git/v5"
|
||||
@@ -269,6 +271,7 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource, er
|
||||
}
|
||||
ensureResourceRequestRawURLs(req, ctx.Res)
|
||||
ctx.Res.CalcSize(nil)
|
||||
d.applyGBlobResourceMetadata(ctx.Res)
|
||||
}
|
||||
res = ctx.Res
|
||||
},
|
||||
@@ -276,6 +279,19 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource, er
|
||||
return
|
||||
}
|
||||
|
||||
func (d *Downloader) applyGBlobResourceMetadata(res *base.Resource) {
|
||||
if d.gblob == nil || res == nil {
|
||||
return
|
||||
}
|
||||
for _, file := range res.Files {
|
||||
if file == nil || file.Req == nil {
|
||||
continue
|
||||
}
|
||||
_ = d.gblob.SetSize(file.Req.URL, file.Size)
|
||||
_ = d.gblob.SetRange(file.Req.URL, res.Range)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Downloader) triggerOnStart(task *Task) {
|
||||
doTrigger(d,
|
||||
EventOnStart,
|
||||
@@ -363,7 +379,10 @@ func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, c
|
||||
}
|
||||
engine, session := d.newExtensionEngine()
|
||||
defer session.CloseIfIdle()
|
||||
err = engine.Runtime.Set("gopeed", gopeed)
|
||||
gopeed.Runtime = &InstanceRuntime{
|
||||
WebView: d.newExtensionWebViewRuntime(session),
|
||||
}
|
||||
err = injectGopeed(engine.Runtime, gopeed)
|
||||
if err != nil {
|
||||
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] engine inject failed", ext.buildIdentity())
|
||||
return
|
||||
@@ -603,32 +622,37 @@ type Option struct {
|
||||
|
||||
// Instance inject to js context when extension script is activated
|
||||
type Instance struct {
|
||||
Events InstanceEvents `json:"events"`
|
||||
Info *ExtensionInfo `json:"info"`
|
||||
Logger *InstanceLogger `json:"logger"`
|
||||
Settings map[string]any `json:"settings"`
|
||||
Storage *ContextStorage `json:"storage"`
|
||||
Events InstanceEvents `json:"events"`
|
||||
Info *ExtensionInfo `json:"info"`
|
||||
Logger *InstanceLogger `json:"logger"`
|
||||
Settings map[string]any `json:"settings"`
|
||||
Storage *ContextStorage `json:"storage"`
|
||||
Runtime *InstanceRuntime `json:"runtime"`
|
||||
}
|
||||
|
||||
type InstanceEvents map[ActivationEvent]goja.Callable
|
||||
type InstanceRuntime struct {
|
||||
WebView *enginewebview.Runtime `json:"webview"`
|
||||
}
|
||||
|
||||
func (h InstanceEvents) register(name ActivationEvent, fn goja.Callable) {
|
||||
type InstanceEvents map[ActivationEvent]engine.JSFunction
|
||||
|
||||
func (h InstanceEvents) register(name ActivationEvent, fn engine.JSFunction) {
|
||||
h[name] = fn
|
||||
}
|
||||
|
||||
func (h InstanceEvents) OnResolve(fn goja.Callable) {
|
||||
func (h InstanceEvents) OnResolve(fn engine.JSFunction) {
|
||||
h.register(EventOnResolve, fn)
|
||||
}
|
||||
|
||||
func (h InstanceEvents) OnStart(fn goja.Callable) {
|
||||
func (h InstanceEvents) OnStart(fn engine.JSFunction) {
|
||||
h.register(EventOnStart, fn)
|
||||
}
|
||||
|
||||
func (h InstanceEvents) OnError(fn goja.Callable) {
|
||||
func (h InstanceEvents) OnError(fn engine.JSFunction) {
|
||||
h.register(EventOnError, fn)
|
||||
}
|
||||
|
||||
func (h InstanceEvents) OnDone(fn goja.Callable) {
|
||||
func (h InstanceEvents) OnDone(fn engine.JSFunction) {
|
||||
h.register(EventOnDone, fn)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type extensionEngineRunner interface {
|
||||
Close()
|
||||
RunString(script string) (any, error)
|
||||
}
|
||||
|
||||
type ExtensionEngine struct {
|
||||
engine extensionEngineRunner
|
||||
session *engineSession
|
||||
}
|
||||
|
||||
func (d *Downloader) NewExtensionEngine(ext *Extension, settings map[string]any) (*ExtensionEngine, error) {
|
||||
if ext == nil {
|
||||
return nil, fmt.Errorf("extension is nil")
|
||||
}
|
||||
engine, session := d.newExtensionEngine()
|
||||
gopeed := &Instance{
|
||||
Events: make(InstanceEvents),
|
||||
Info: NewExtensionInfo(ext),
|
||||
Logger: newInstanceLogger(ext, d.ExtensionLogger),
|
||||
Settings: settings,
|
||||
Storage: &ContextStorage{
|
||||
storage: d.storage,
|
||||
identity: ext.buildIdentity(),
|
||||
},
|
||||
Runtime: &InstanceRuntime{
|
||||
WebView: d.newExtensionWebViewRuntime(session),
|
||||
},
|
||||
}
|
||||
if err := injectGopeed(engine.Runtime, gopeed); err != nil {
|
||||
session.CloseIfIdle()
|
||||
return nil, err
|
||||
}
|
||||
return &ExtensionEngine{
|
||||
engine: engine,
|
||||
session: session,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *ExtensionEngine) Eval(script string) (any, error) {
|
||||
if r == nil || r.engine == nil {
|
||||
return nil, fmt.Errorf("extension engine not initialized")
|
||||
}
|
||||
return r.engine.RunString(script)
|
||||
}
|
||||
|
||||
func (r *ExtensionEngine) EvalFile(path string) (any, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("script path is empty")
|
||||
}
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Eval(string(buf))
|
||||
}
|
||||
|
||||
func (r *ExtensionEngine) Close() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
if r.session != nil {
|
||||
r.session.CloseIfIdle()
|
||||
} else if r.engine != nil {
|
||||
r.engine.Close()
|
||||
}
|
||||
r.engine = nil
|
||||
r.session = nil
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/protocol/gblob"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/stream"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
@@ -15,6 +17,7 @@ type engineSession struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
closed bool
|
||||
close []func()
|
||||
}
|
||||
|
||||
func newEngineSession(e *engine.Engine) *engineSession {
|
||||
@@ -47,6 +50,7 @@ func (s *engineSession) Release() {
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if shouldClose {
|
||||
s.runClosers()
|
||||
if s.engine != nil {
|
||||
go s.engine.Close()
|
||||
}
|
||||
@@ -61,12 +65,37 @@ func (s *engineSession) CloseIfIdle() {
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if shouldClose {
|
||||
s.runClosers()
|
||||
if s.engine != nil {
|
||||
s.engine.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *engineSession) OnClose(fn func()) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
fn()
|
||||
return
|
||||
}
|
||||
s.close = append(s.close, fn)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *engineSession) runClosers() {
|
||||
s.mu.Lock()
|
||||
closeFns := s.close
|
||||
s.close = nil
|
||||
s.mu.Unlock()
|
||||
for _, fn := range closeFns {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStreamChunk(data any) ([]byte, error) {
|
||||
switch v := data.(type) {
|
||||
case nil:
|
||||
@@ -88,8 +117,18 @@ func (d *Downloader) newExtensionEngine() (*engine.Engine, *engineSession) {
|
||||
CreateBlobObjectURL: func(data []byte, contentType string) (string, error) {
|
||||
return d.gblob.CreateBlob(data, contentType)
|
||||
},
|
||||
CreateWritableStreamObjectURL: func() (string, error) {
|
||||
return d.gblob.CreateWritableStream(session)
|
||||
CreateWritableStreamObjectURL: func(opts *stream.WritableStreamObjectURLOptions) (string, error) {
|
||||
reopenable := false
|
||||
if opts != nil {
|
||||
reopenable = opts.Reopenable
|
||||
}
|
||||
return d.gblob.CreateWritableStream(&gblob.CreateWritableStreamOptions{
|
||||
Session: session,
|
||||
Reopenable: reopenable,
|
||||
})
|
||||
},
|
||||
RegisterWritableStreamResume: func(url string, reopen func(offset int64) error) error {
|
||||
return d.gblob.SetResumeOpener(url, reopen)
|
||||
},
|
||||
WriteWritableStreamObjectURL: func(url string, data any) error {
|
||||
chunk, err := normalizeStreamChunk(data)
|
||||
@@ -107,6 +146,7 @@ func (d *Downloader) newExtensionEngine() (*engine.Engine, *engineSession) {
|
||||
RevokeObjectURL: func(url string) error {
|
||||
return d.gblob.Revoke(url)
|
||||
},
|
||||
ProxyHandler: d.cfg.Proxy.ToHandler(),
|
||||
}
|
||||
e := engine.NewEngine(&engine.Config{
|
||||
ProxyConfig: d.cfg.Proxy,
|
||||
@@ -115,3 +155,19 @@ func (d *Downloader) newExtensionEngine() (*engine.Engine, *engineSession) {
|
||||
session.SetEngine(e)
|
||||
return e, session
|
||||
}
|
||||
|
||||
func (d *Downloader) newExtensionWebViewRuntime(session *engineSession) *enginewebview.Runtime {
|
||||
var (
|
||||
opener enginewebview.Opener
|
||||
available bool
|
||||
)
|
||||
if provider := d.cfg.WebViewProvider; provider != nil && provider.IsAvailable() {
|
||||
opener = provider
|
||||
available = true
|
||||
}
|
||||
runtime := enginewebview.NewRuntime(opener, available)
|
||||
session.OnClose(func() {
|
||||
_ = runtime.Close()
|
||||
})
|
||||
return runtime
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func injectGopeed(vm *goja.Runtime, gopeed *Instance) error {
|
||||
gopeedObject := vm.NewObject()
|
||||
if gopeed == nil {
|
||||
return vm.Set("gopeed", gopeedObject)
|
||||
}
|
||||
if err := gopeedObject.Set("events", newJSEventsRuntime(vm, gopeed.Events)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gopeedObject.Set("info", gopeed.Info); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gopeedObject.Set("logger", gopeed.Logger); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gopeedObject.Set("settings", gopeed.Settings); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gopeedObject.Set("storage", gopeed.Storage); err != nil {
|
||||
return err
|
||||
}
|
||||
runtimeObject := vm.NewObject()
|
||||
if gopeed.Runtime != nil && gopeed.Runtime.WebView != nil {
|
||||
if err := runtimeObject.Set("webview", newJSWebViewRuntime(vm, gopeed.Runtime.WebView)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := gopeedObject.Set("runtime", runtimeObject); err != nil {
|
||||
return err
|
||||
}
|
||||
return vm.Set("gopeed", gopeedObject)
|
||||
}
|
||||
|
||||
func newJSEventsRuntime(vm *goja.Runtime, events InstanceEvents) *goja.Object {
|
||||
obj := vm.NewObject()
|
||||
register := func(event ActivationEvent, call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) == 0 {
|
||||
panic(vm.ToValue(fmt.Errorf("missing handler")))
|
||||
}
|
||||
fnValue := call.Argument(0)
|
||||
exported, ok := fnValue.Export().(func(goja.FunctionCall) goja.Value)
|
||||
if !ok {
|
||||
panic(vm.ToValue(fmt.Errorf("handler must be a function")))
|
||||
}
|
||||
events.register(event, engine.JSFunction(exported))
|
||||
return goja.Undefined()
|
||||
}
|
||||
_ = obj.Set("onResolve", func(call goja.FunctionCall) goja.Value {
|
||||
return register(EventOnResolve, call)
|
||||
})
|
||||
_ = obj.Set("onStart", func(call goja.FunctionCall) goja.Value {
|
||||
return register(EventOnStart, call)
|
||||
})
|
||||
_ = obj.Set("onError", func(call goja.FunctionCall) goja.Value {
|
||||
return register(EventOnError, call)
|
||||
})
|
||||
_ = obj.Set("onDone", func(call goja.FunctionCall) goja.Value {
|
||||
return register(EventOnDone, call)
|
||||
})
|
||||
return obj
|
||||
}
|
||||
|
||||
func newJSWebViewRuntime(vm *goja.Runtime, runtime *enginewebview.Runtime) *goja.Object {
|
||||
obj := vm.NewObject()
|
||||
_ = obj.Set("isAvailable", func(goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(runtime.IsAvailable())
|
||||
})
|
||||
_ = obj.Set("open", func(call goja.FunctionCall) goja.Value {
|
||||
page, err := runtime.Open(optionalMap(call.Argument(0)))
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return newJSWebViewPage(vm, page)
|
||||
})
|
||||
return obj
|
||||
}
|
||||
|
||||
func newJSWebViewPage(vm *goja.Runtime, page *enginewebview.PageHandle) *goja.Object {
|
||||
obj := vm.NewObject()
|
||||
_ = obj.Set("addInitScript", func(call goja.FunctionCall) goja.Value {
|
||||
script, err := requireStringArg(call, 0, "script")
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
if err := page.AddInitScript(script); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("goto", func(call goja.FunctionCall) goja.Value {
|
||||
url, err := requireStringArg(call, 0, "url")
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
if err := page.Goto(
|
||||
url,
|
||||
optionalMap(call.Argument(1)),
|
||||
); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("execute", func(call goja.FunctionCall) goja.Value {
|
||||
expression, err := enginewebview.NormalizeExecutableValue(call.Argument(0))
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
result, err := page.Execute(expression, exportArgs(call.Arguments[1:])...)
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
_ = obj.Set("focus", func(call goja.FunctionCall) goja.Value {
|
||||
selector, err := requireStringArg(call, 0, "selector")
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
if err := page.Focus(selector); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("click", func(call goja.FunctionCall) goja.Value {
|
||||
selector, err := requireStringArg(call, 0, "selector")
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
if err := page.Click(selector, optionalMap(call.Argument(1))); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("type", func(call goja.FunctionCall) goja.Value {
|
||||
selector, err := requireStringArg(call, 0, "selector")
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
text, err := requireStringArg(call, 1, "text")
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
if err := page.Type(selector, text, optionalMap(call.Argument(2))); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("waitForSelector", func(call goja.FunctionCall) goja.Value {
|
||||
selector, err := requireStringArg(call, 0, "selector")
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
result, err := page.WaitForSelector(
|
||||
selector,
|
||||
optionalMap(call.Argument(1)),
|
||||
)
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
_ = obj.Set("waitForFunction", func(call goja.FunctionCall) goja.Value {
|
||||
expression, err := enginewebview.NormalizeExecutableValue(call.Argument(0))
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
args := exportArgs(call.Arguments[1:])
|
||||
result, err := page.WaitForFunction(expression, args...)
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
_ = obj.Set("getCookies", func(goja.FunctionCall) goja.Value {
|
||||
result, err := page.GetCookies()
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
_ = obj.Set("setCookie", func(call goja.FunctionCall) goja.Value {
|
||||
if err := page.SetCookie(optionalMap(call.Argument(0))); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("deleteCookie", func(call goja.FunctionCall) goja.Value {
|
||||
if err := page.DeleteCookie(optionalMap(call.Argument(0))); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("clearCookies", func(goja.FunctionCall) goja.Value {
|
||||
if err := page.ClearCookies(); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("url", func(goja.FunctionCall) goja.Value {
|
||||
result, err := page.URL()
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
_ = obj.Set("content", func(goja.FunctionCall) goja.Value {
|
||||
result, err := page.Content()
|
||||
if err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
_ = obj.Set("close", func(goja.FunctionCall) goja.Value {
|
||||
if err := page.Close(); err != nil {
|
||||
panic(vm.ToValue(err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
return obj
|
||||
}
|
||||
|
||||
func optionalMap(value goja.Value) map[string]any {
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return nil
|
||||
}
|
||||
if exported, ok := value.Export().(map[string]any); ok {
|
||||
return exported
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportArgs(values []goja.Value) []any {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
args := make([]any, 0, len(values))
|
||||
for _, value := range values {
|
||||
args = append(args, value.Export())
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func requireStringArg(call goja.FunctionCall, index int, name string) (string, error) {
|
||||
value := call.Argument(index)
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return "", fmt.Errorf(`missing or invalid "%s"`, name)
|
||||
}
|
||||
return value.String(), nil
|
||||
}
|
||||
+1406
-84
File diff suppressed because it is too large
Load Diff
@@ -1,87 +0,0 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func NewExtMockDownloader() (*Downloader, func(), error) {
|
||||
d := NewDownloader(&DownloaderConfig{
|
||||
Storage: NewMemStorage(),
|
||||
})
|
||||
if err := d.Setup(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = d.Clear()
|
||||
}
|
||||
return d, cleanup, nil
|
||||
}
|
||||
|
||||
type ExtMockRuntime struct {
|
||||
engine engineWrapper
|
||||
session *engineSession
|
||||
}
|
||||
|
||||
type engineWrapper interface {
|
||||
Close()
|
||||
RunString(script string) (any, error)
|
||||
}
|
||||
|
||||
func (d *Downloader) NewExtMockRuntime() (*ExtMockRuntime, error) {
|
||||
engine, session := d.newExtensionEngine()
|
||||
ext := &Extension{
|
||||
Name: "extmock",
|
||||
Author: "gopeed",
|
||||
Title: "Gopeed ExtMock Runtime",
|
||||
Version: "0.0.0",
|
||||
DevMode: true,
|
||||
}
|
||||
gopeed := &Instance{
|
||||
Events: make(InstanceEvents),
|
||||
Info: NewExtensionInfo(ext),
|
||||
Logger: newInstanceLogger(ext, d.ExtensionLogger),
|
||||
Settings: map[string]any{},
|
||||
Storage: &ContextStorage{
|
||||
storage: d.storage,
|
||||
identity: ext.buildIdentity(),
|
||||
},
|
||||
}
|
||||
if err := engine.Runtime.Set("gopeed", gopeed); err != nil {
|
||||
engine.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &ExtMockRuntime{
|
||||
engine: engine,
|
||||
session: session,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *ExtMockRuntime) Eval(script string) (any, error) {
|
||||
if r == nil || r.engine == nil {
|
||||
return nil, fmt.Errorf("extmock runtime not initialized")
|
||||
}
|
||||
return r.engine.RunString(script)
|
||||
}
|
||||
|
||||
func (r *ExtMockRuntime) EvalFile(path string) (any, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("script path is empty")
|
||||
}
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Eval(string(buf))
|
||||
}
|
||||
|
||||
func (r *ExtMockRuntime) Close() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
if r.engine != nil {
|
||||
r.engine.Close()
|
||||
}
|
||||
r.engine = nil
|
||||
r.session = nil
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/GopeedLab/gopeed/internal/protocol/gblob"
|
||||
"github.com/GopeedLab/gopeed/internal/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
)
|
||||
@@ -28,7 +29,6 @@ type Task struct {
|
||||
Status base.Status `json:"status"`
|
||||
Uploading bool `json:"uploading"`
|
||||
Progress *Progress `json:"progress"`
|
||||
IsCreated bool `json:"isCreated"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
@@ -51,7 +51,6 @@ func NewTask() *Task {
|
||||
Status: base.DownloadStatusReady,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
IsCreated: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +153,7 @@ type DownloaderConfig struct {
|
||||
Storage Storage
|
||||
StorageDir string
|
||||
WhiteDownloadDirs []string
|
||||
WebViewProvider enginewebview.Provider
|
||||
|
||||
ProductionMode bool
|
||||
|
||||
|
||||
+133
-12
@@ -1,4 +1,6 @@
|
||||
gopeed.events.onResolve(async function (ctx) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
if (ctx.req.url.endsWith("/blob")) {
|
||||
const blob = new Blob(["hello world"], {type: "text/plain"});
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -17,17 +19,132 @@ gopeed.events.onResolve(async function (ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transform = new TransformStream();
|
||||
const writer = transform.writable.getWriter();
|
||||
const url = URL.createObjectURL(transform.writable);
|
||||
if (ctx.req.url.includes("/http-stream")) {
|
||||
const source = new URL(ctx.req.url);
|
||||
const target = source.searchParams.get("target");
|
||||
const pair = source.searchParams.get("pair") === "1";
|
||||
const resumable = ctx.req.url.includes("/http-stream-range");
|
||||
const fileName = source.searchParams.get("name") || "http-stream.bin";
|
||||
const headResponse = await fetch(target, {method: "HEAD"});
|
||||
const headLength = parseInt(headResponse.headers.get("content-length") || "", 10);
|
||||
|
||||
const createOpen = () => async (offset) => {
|
||||
const headers = {};
|
||||
if (offset > 0) {
|
||||
headers.Range = `bytes=${offset}-`;
|
||||
}
|
||||
const response = await fetch(target, {headers});
|
||||
if (!response.body) {
|
||||
throw new Error("empty response body");
|
||||
}
|
||||
return response.body;
|
||||
};
|
||||
|
||||
ctx.res = {
|
||||
name: "gblob-http-stream",
|
||||
range: resumable,
|
||||
files: pair ? [
|
||||
{
|
||||
name: "pair-video.bin",
|
||||
size: Number.isFinite(headLength) ? headLength : 0,
|
||||
req: {
|
||||
url: URL.createObjectURL(createOpen()),
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "pair-audio.bin",
|
||||
size: Number.isFinite(headLength) ? headLength : 0,
|
||||
req: {
|
||||
url: URL.createObjectURL(createOpen()),
|
||||
}
|
||||
}
|
||||
] : [
|
||||
{
|
||||
name: fileName,
|
||||
size: Number.isFinite(headLength) ? headLength : 0,
|
||||
req: {
|
||||
url: URL.createObjectURL(createOpen()),
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.req.url.includes("/http-blob")) {
|
||||
const source = new URL(ctx.req.url);
|
||||
const target = source.searchParams.get("target");
|
||||
const fileName = source.searchParams.get("name") || "http-blob.bin";
|
||||
const headResponse = await fetch(target, {method: "HEAD"});
|
||||
const headLength = parseInt(headResponse.headers.get("content-length") || "", 10);
|
||||
const response = await fetch(target);
|
||||
const blob = await response.blob();
|
||||
|
||||
ctx.res = {
|
||||
name: "gblob-http-blob",
|
||||
files: [
|
||||
{
|
||||
name: fileName,
|
||||
size: Number.isFinite(headLength) ? headLength : 0,
|
||||
req: {
|
||||
url: URL.createObjectURL(blob),
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.req.url.endsWith("/stream-range")) {
|
||||
const payload = encoder.encode("line 1\nline 2\nline 3\n");
|
||||
const firstChunkLength = encoder.encode("line 1\n").byteLength;
|
||||
let firstAttempt = true;
|
||||
|
||||
const open = async (offset) => new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
if (offset === 0 && firstAttempt) {
|
||||
firstAttempt = false;
|
||||
controller.enqueue(payload.slice(0, firstChunkLength));
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
controller.error(new Error("resume required"));
|
||||
return;
|
||||
}
|
||||
controller.enqueue(payload.slice(offset));
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
controller.error(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(open);
|
||||
|
||||
ctx.res = {
|
||||
name: "gblob-stream-range",
|
||||
range: true,
|
||||
files: [
|
||||
{
|
||||
name: "stream-range.txt",
|
||||
size: payload.byteLength,
|
||||
req: {
|
||||
url,
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.req.url.endsWith("/stream-unknown")) {
|
||||
(async () => {
|
||||
await writer.write(new TextEncoder().encode("line 1\n"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
await writer.write(new TextEncoder().encode("line 2\n"));
|
||||
await writer.close();
|
||||
})();
|
||||
const url = URL.createObjectURL(new ReadableStream({
|
||||
async start(controller) {
|
||||
controller.enqueue(encoder.encode("line 1\n"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
controller.enqueue(encoder.encode("line 2\n"));
|
||||
controller.close();
|
||||
},
|
||||
}));
|
||||
|
||||
ctx.res = {
|
||||
name: "gblob-stream-unknown",
|
||||
@@ -43,9 +160,13 @@ gopeed.events.onResolve(async function (ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
await writer.write(new TextEncoder().encode("line 1\n"));
|
||||
await writer.write(new TextEncoder().encode("line 2\n"));
|
||||
await writer.close();
|
||||
const url = URL.createObjectURL(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode("line 1\n"));
|
||||
controller.enqueue(encoder.encode("line 2\n"));
|
||||
controller.close();
|
||||
},
|
||||
}));
|
||||
|
||||
ctx.res = {
|
||||
name: "gblob-stream",
|
||||
|
||||
+7
-9
@@ -3,15 +3,13 @@ gopeed.events.onResolve(async function (ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transform = new TransformStream();
|
||||
const writer = transform.writable.getWriter();
|
||||
const url = URL.createObjectURL(transform.writable);
|
||||
|
||||
(async () => {
|
||||
await writer.write(new TextEncoder().encode("stale\n"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await writer.abort("expired");
|
||||
})();
|
||||
const url = URL.createObjectURL(new ReadableStream({
|
||||
async start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("stale\n"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
controller.error(new Error("expired"));
|
||||
},
|
||||
}));
|
||||
|
||||
ctx.res = {
|
||||
name: "gblob-recover",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
function createRangeGBlobUrl(target) {
|
||||
return URL.createObjectURL(async (offset) => {
|
||||
const headers = {};
|
||||
if (offset > 0) {
|
||||
headers.Range = `bytes=${offset}-`;
|
||||
}
|
||||
const response = await fetch(target, { headers });
|
||||
if (!response.body) {
|
||||
throw new Error('empty response body');
|
||||
}
|
||||
return response.body;
|
||||
});
|
||||
}
|
||||
|
||||
gopeed.events.onResolve(async function (ctx) {
|
||||
if (!ctx.req.url.includes('/restart-range')) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.res = {
|
||||
name: 'gblob-restart',
|
||||
range: true,
|
||||
files: [
|
||||
{
|
||||
name: 'restart.bin',
|
||||
size: 262144,
|
||||
req: {
|
||||
url: createRangeGBlobUrl(ctx.req.rawUrl || ctx.req.url),
|
||||
rawUrl: ctx.req.rawUrl || ctx.req.url,
|
||||
labels: {
|
||||
mode: 'restart',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
gopeed.events.onError(async function (ctx) {
|
||||
const req = ctx.task?.meta?.req;
|
||||
if (!req || !req.rawUrl || !req.url || !req.url.startsWith('gblob:')) {
|
||||
return;
|
||||
}
|
||||
req.labels = req.labels || {};
|
||||
if (req.labels.rebuilt === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
req.url = createRangeGBlobUrl(req.rawUrl);
|
||||
req.labels.started = 'true';
|
||||
req.labels.rebuilt = 'true';
|
||||
ctx.task.continue();
|
||||
} catch (error) {
|
||||
req.labels.rebuildError = String(error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "gblob-restart",
|
||||
"title": "gopeed extension gblob restart resume test",
|
||||
"version": "0.0.1",
|
||||
"scripts": [
|
||||
{
|
||||
"event": "onResolve",
|
||||
"match": {
|
||||
"urls": [
|
||||
"*://*/restart-range*"
|
||||
]
|
||||
},
|
||||
"entry": "index.js"
|
||||
},
|
||||
{
|
||||
"event": "onError",
|
||||
"match": {
|
||||
"labels": [
|
||||
"mode"
|
||||
],
|
||||
"urls": [
|
||||
"*://*/restart-range*"
|
||||
]
|
||||
},
|
||||
"entry": "index.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package model
|
||||
|
||||
import (
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
@@ -21,8 +22,10 @@ type StartConfig struct {
|
||||
WhiteDownloadDirs []string `json:"whiteDownloadDirs"`
|
||||
ApiToken string `json:"apiToken"`
|
||||
DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"`
|
||||
WebViewRPCConfig *enginewebview.RPCConfig `json:"webViewRpcConfig,omitempty"`
|
||||
|
||||
ProductionMode bool
|
||||
ProductionMode bool
|
||||
WebViewProvider enginewebview.Provider `json:"-"`
|
||||
|
||||
WebEnable bool
|
||||
WebFS fs.FS
|
||||
|
||||
@@ -89,6 +89,7 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
ProductionMode: startCfg.ProductionMode,
|
||||
RefreshInterval: startCfg.RefreshInterval,
|
||||
WhiteDownloadDirs: startCfg.WhiteDownloadDirs,
|
||||
WebViewProvider: startCfg.WebViewProvider,
|
||||
}
|
||||
if startCfg.Storage == model.StorageBolt {
|
||||
downloadCfg.Storage = download.NewBoltStorage(startCfg.StorageDir)
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
|
||||
@@ -288,6 +289,10 @@ func TestPatchTaskNotFound(t *testing.T) {
|
||||
|
||||
func TestPauseAllAndContinueALLTasks(t *testing.T) {
|
||||
doTest(func() {
|
||||
slowListener := test.StartTestLowSpeedServer(5 * time.Nanosecond)
|
||||
defer slowListener.Close()
|
||||
taskReq.URL = "http://" + slowListener.Addr().String() + "/" + test.BuildName
|
||||
|
||||
cfg, err := Downloader.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -875,6 +880,55 @@ func TestAuthorization(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServerPropagatesWebViewProvider(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := listener.Addr().String()
|
||||
listener.Close()
|
||||
|
||||
cfg := &model.StartConfig{
|
||||
Network: "tcp",
|
||||
Address: addr,
|
||||
Storage: model.StorageMem,
|
||||
WebViewProvider: fakeRestWebViewProvider{available: true},
|
||||
}
|
||||
|
||||
server, serverListener, err := BuildServer(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer serverListener.Close()
|
||||
defer server.Close()
|
||||
defer func() {
|
||||
if Downloader != nil {
|
||||
Downloader.Clear()
|
||||
Downloader = nil
|
||||
}
|
||||
}()
|
||||
|
||||
runtime, err := Downloader.NewExtensionEngine(&download.Extension{
|
||||
Name: "test-runtime",
|
||||
Author: "gopeed",
|
||||
Title: "Gopeed Test Script Runtime",
|
||||
Version: "0.0.0",
|
||||
DevMode: true,
|
||||
}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
|
||||
value, err := runtime.Eval("gopeed.runtime.webview.isAvailable()")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != true {
|
||||
t.Fatalf("expected provider to reach downloader runtime, got %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func doTest(handler func()) {
|
||||
doTest0(nil, handler)
|
||||
}
|
||||
@@ -919,6 +973,52 @@ func doStart(cfg *model.StartConfig) net.Listener {
|
||||
return test.StartTestFileServer()
|
||||
}
|
||||
|
||||
type fakeRestWebViewProvider struct {
|
||||
available bool
|
||||
}
|
||||
|
||||
func (p fakeRestWebViewProvider) IsAvailable() bool {
|
||||
return p.available
|
||||
}
|
||||
|
||||
func (p fakeRestWebViewProvider) Open(enginewebview.OpenOptions) (enginewebview.Page, error) {
|
||||
return fakeRestPage{}, nil
|
||||
}
|
||||
|
||||
type fakeRestPage struct{}
|
||||
|
||||
func (fakeRestPage) AddInitScript(string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeRestPage) Goto(string, enginewebview.GotoOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeRestPage) Execute(string, ...any) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeRestPage) GetCookies() ([]enginewebview.Cookie, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeRestPage) SetCookie(enginewebview.Cookie) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeRestPage) DeleteCookie(enginewebview.Cookie) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeRestPage) ClearCookies() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fakeRestPage) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func doHttpRequest0(method string, path string, headers map[string]string, body any) (int, []byte) {
|
||||
r1, _, r3 := doHttpRequest1(method, path, headers, body)
|
||||
return r1, r3
|
||||
|
||||
@@ -24,7 +24,7 @@ if (flutterVersionName == null) {
|
||||
|
||||
android {
|
||||
namespace 'com.gopeed.gopeed'
|
||||
compileSdk 35
|
||||
compileSdk 36
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
|
||||
@@ -46,7 +46,7 @@ class _Client {
|
||||
dio.options.contentType = Headers.jsonContentType;
|
||||
dio.options.sendTimeout = const Duration(seconds: 5);
|
||||
dio.options.connectTimeout = const Duration(seconds: 5);
|
||||
dio.options.receiveTimeout = const Duration(seconds: 60);
|
||||
dio.options.receiveTimeout = const Duration(seconds: 120);
|
||||
dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
if (apiToken.isNotEmpty) {
|
||||
|
||||
@@ -49,7 +49,7 @@ Map<String, dynamic> _$CreateTaskBatchItemToJson(CreateTaskBatchItem instance) {
|
||||
}
|
||||
}
|
||||
|
||||
writeNotNull('req', instance.req);
|
||||
writeNotNull('opts', instance.opts);
|
||||
writeNotNull('req', instance.req?.toJson());
|
||||
writeNotNull('opts', instance.opts?.toJson());
|
||||
return val;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ part 'request.g.dart';
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class Request {
|
||||
String? rawUrl;
|
||||
String url;
|
||||
Object? extra;
|
||||
Map<String, String>? labels;
|
||||
@@ -11,6 +12,7 @@ class Request {
|
||||
bool skipVerifyCert;
|
||||
|
||||
Request({
|
||||
this.rawUrl,
|
||||
required this.url,
|
||||
this.extra,
|
||||
this.labels,
|
||||
|
||||
@@ -7,6 +7,7 @@ part of 'request.dart';
|
||||
// **************************************************************************
|
||||
|
||||
Request _$RequestFromJson(Map<String, dynamic> json) => Request(
|
||||
rawUrl: json['rawUrl'] as String?,
|
||||
url: json['url'] as String,
|
||||
extra: json['extra'],
|
||||
labels: (json['labels'] as Map<String, dynamic>?)?.map(
|
||||
@@ -20,6 +21,7 @@ Request _$RequestFromJson(Map<String, dynamic> json) => Request(
|
||||
|
||||
Map<String, dynamic> _$RequestToJson(Request instance) {
|
||||
final val = <String, dynamic>{
|
||||
'rawUrl': instance.rawUrl,
|
||||
'url': instance.url,
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ part 'task.g.dart';
|
||||
|
||||
enum Status { ready, running, pause, wait, error, done }
|
||||
|
||||
enum Protocol { http, bt, ed2k }
|
||||
enum Protocol { http, bt, ed2k, gblob }
|
||||
|
||||
// ExtractStatus enum matching Go backend
|
||||
enum ExtractStatus {
|
||||
|
||||
@@ -52,6 +52,7 @@ const _$ProtocolEnumMap = {
|
||||
Protocol.http: 'http',
|
||||
Protocol.bt: 'bt',
|
||||
Protocol.ed2k: 'ed2k',
|
||||
Protocol.gblob: 'gblob',
|
||||
};
|
||||
|
||||
Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress(
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -17,13 +16,11 @@ import 'package:uri_to_file/uri_to_file.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
|
||||
import '../../../../api/api.dart';
|
||||
import '../../../../api/model/create_task.dart';
|
||||
import '../../../../api/model/downloader_config.dart';
|
||||
import '../../../../api/model/install_extension.dart';
|
||||
import '../../../../api/model/request.dart';
|
||||
import '../../../../api/model/result.dart';
|
||||
import '../../../../core/common/start_config.dart';
|
||||
import '../../../../core/libgopeed_boot.dart';
|
||||
import '../../../../database/database.dart';
|
||||
@@ -37,7 +34,8 @@ import '../../../../util/package_info.dart';
|
||||
import '../../../../util/updater.dart';
|
||||
import '../../../../util/util.dart';
|
||||
import '../../../routes/app_pages.dart';
|
||||
import '../../../rpc/rpc.dart';
|
||||
import '../../../rpc/host_rpc_service.dart';
|
||||
import '../../../rpc/webview_rpc_service.dart';
|
||||
import '../../redirect/views/redirect_view.dart';
|
||||
import '../../../services/notification_service.dart';
|
||||
|
||||
@@ -98,7 +96,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
|
||||
_initWindows().onError((error, stackTrace) =>
|
||||
logger.w("initWindows error", error, stackTrace));
|
||||
|
||||
|
||||
if (Util.isDesktop()) {
|
||||
Get.put(NotificationService());
|
||||
}
|
||||
@@ -126,6 +124,8 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
void onClose() {
|
||||
_linkSubscription?.cancel();
|
||||
trayManager.removeListener(this);
|
||||
HostRpcService.instance.stop();
|
||||
WebViewRpcService.instance.stop();
|
||||
LibgopeedBoot.instance.stop();
|
||||
}
|
||||
|
||||
@@ -330,54 +330,33 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await startRpcServer({
|
||||
"/create": (ctx) async {
|
||||
final meta =
|
||||
ctx.request.headers["X-Gopeed-Host-Meta"]?.firstOrNull ?? "{}";
|
||||
final jsonMeta = jsonDecode(meta);
|
||||
final silent = jsonMeta['silent'] as bool? ?? false;
|
||||
final params = await ctx.readText();
|
||||
final createTaskParams = CreateTask.fromJson(_decodeParams(params));
|
||||
await HostRpcService.instance.start(
|
||||
onCreate: (createTaskParams, silent) async {
|
||||
if (!silent || pendingUpdateTask.value != null) {
|
||||
await windowManager.show();
|
||||
_handleToCreate0(createTaskParams);
|
||||
} else {
|
||||
try {
|
||||
await createTask(createTaskParams);
|
||||
} catch (e) {
|
||||
logger.w(
|
||||
"create task from extension fail", e, StackTrace.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
"/forward": (ctx) async {
|
||||
try {
|
||||
final body = await ctx.readJSON();
|
||||
final method = (body['method'] as String?)?.toUpperCase() ?? 'GET';
|
||||
final path = (body['path'] as String?) ?? "/";
|
||||
final data = body['data'];
|
||||
final query = body['query'] as Map<String, dynamic>?;
|
||||
|
||||
// Forward request to gopeed REST API
|
||||
final response = await forward(
|
||||
path,
|
||||
method: method,
|
||||
data: data,
|
||||
queryParameters: query,
|
||||
);
|
||||
|
||||
// Return raw response
|
||||
await ctx.writeJSON(response.data);
|
||||
await createTask(createTaskParams);
|
||||
} catch (e) {
|
||||
if (e is DioException && e.response != null) {
|
||||
// Return API error response
|
||||
await ctx.writeJSON(e.response!.data);
|
||||
} else {
|
||||
await ctx.writeJSON(Result(code: 1, msg: e.toString()).toJson());
|
||||
}
|
||||
logger.w("create task from extension fail", e, StackTrace.current);
|
||||
}
|
||||
},
|
||||
});
|
||||
onForward: ({
|
||||
required String path,
|
||||
required String method,
|
||||
data,
|
||||
Map<String, dynamic>? query,
|
||||
}) {
|
||||
return forward(
|
||||
path,
|
||||
method: method,
|
||||
data: data,
|
||||
queryParameters: query,
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
logger.w("start rpc server fail", e, StackTrace.current);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import '../../../../i18n/message.dart';
|
||||
import '../../../../theme/theme.dart';
|
||||
import '../../../../util/locale_manager.dart';
|
||||
import '../../../../util/util.dart'; // Import the required packages
|
||||
import '../../../rpc/webview_rpc_overlay.dart';
|
||||
import '../../../rpc/webview_rpc_service.dart';
|
||||
import '../../../routes/app_pages.dart';
|
||||
import '../controllers/app_controller.dart';
|
||||
|
||||
@@ -46,9 +48,13 @@ class AppView extends GetView<AppController> {
|
||||
}
|
||||
// Fix for GetX Overlay issue with Flutter 3.38.1+
|
||||
// Reference: https://github.com/jonataslaw/getx/issues/3425
|
||||
return Overlay(
|
||||
initialEntries: [OverlayEntry(builder: (_) => child!)],
|
||||
);
|
||||
final entries = <OverlayEntry>[
|
||||
OverlayEntry(builder: (_) => child!),
|
||||
];
|
||||
if (WebViewRpcService.instance.supported) {
|
||||
entries.add(OverlayEntry(builder: (_) => const WebViewRpcOverlay()));
|
||||
}
|
||||
return Overlay(initialEntries: entries);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -17,6 +17,14 @@ import 'task_downloading_view.dart';
|
||||
class TaskView extends GetView<TaskController> {
|
||||
const TaskView({Key? key}) : super(key: key);
|
||||
|
||||
String? _displayTaskUrl(Task? task) {
|
||||
final rawUrl = task?.meta.req.rawUrl;
|
||||
if (rawUrl != null && rawUrl.isNotEmpty) {
|
||||
return rawUrl;
|
||||
}
|
||||
return task?.meta.req.url;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectTask = controller.selectTask;
|
||||
@@ -85,9 +93,8 @@ class TaskView extends GetView<TaskController> {
|
||||
subtitle: buildTooltipSubtitle(selectTask.value?.name)),
|
||||
ListTile(
|
||||
title: Text('taskUrl'.tr),
|
||||
subtitle:
|
||||
buildTooltipSubtitle(selectTask.value?.meta.req.url),
|
||||
trailing: CopyButton(selectTask.value?.meta.req.url),
|
||||
subtitle: buildTooltipSubtitle(_displayTaskUrl(selectTask.value)),
|
||||
trailing: CopyButton(_displayTaskUrl(selectTask.value)),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('downloadPath'.tr),
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../api/model/create_task.dart';
|
||||
import '../../api/model/result.dart';
|
||||
import 'server.dart';
|
||||
|
||||
typedef HostCreateHandler = Future<void> Function(
|
||||
CreateTask createTask,
|
||||
bool silent,
|
||||
);
|
||||
|
||||
typedef HostForwardHandler = Future<dynamic> Function({
|
||||
required String path,
|
||||
required String method,
|
||||
dynamic data,
|
||||
Map<String, dynamic>? query,
|
||||
});
|
||||
|
||||
class HostRpcService {
|
||||
HostRpcService._();
|
||||
|
||||
static final HostRpcService instance = HostRpcService._();
|
||||
|
||||
RpcServerHandle? _server;
|
||||
|
||||
Future<void> start({
|
||||
required HostCreateHandler onCreate,
|
||||
required HostForwardHandler onForward,
|
||||
}) async {
|
||||
if (_server != null) {
|
||||
return;
|
||||
}
|
||||
_server = await startRpcServer(
|
||||
routes: {
|
||||
'/create': (ctx) async {
|
||||
final meta =
|
||||
ctx.request.headers['X-Gopeed-Host-Meta']?.firstOrNull ?? '{}';
|
||||
final jsonMeta = jsonDecode(meta);
|
||||
final silent = jsonMeta['silent'] as bool? ?? false;
|
||||
final params = await ctx.readText();
|
||||
final createTask = CreateTask.fromJson(_decodeParams(params));
|
||||
await onCreate(createTask, silent);
|
||||
},
|
||||
'/forward': (ctx) async {
|
||||
try {
|
||||
final body = await ctx.readJSON();
|
||||
final method = (body['method'] as String?)?.toUpperCase() ?? 'GET';
|
||||
final path = (body['path'] as String?) ?? '/';
|
||||
final data = body['data'];
|
||||
final query = body['query'] as Map<String, dynamic>?;
|
||||
final response = await onForward(
|
||||
path: path,
|
||||
method: method,
|
||||
data: data,
|
||||
query: query,
|
||||
);
|
||||
if (response is Map<String, dynamic>) {
|
||||
await ctx.writeJSON(response);
|
||||
} else {
|
||||
await ctx.writeJSON((response as dynamic).data);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is DioException && e.response != null) {
|
||||
await ctx.writeJSON(e.response!.data);
|
||||
} else {
|
||||
await ctx.writeJSON(Result(code: 1, msg: e.toString()).toJson());
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
final server = _server;
|
||||
_server = null;
|
||||
if (server != null) {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decodeParams(String params) {
|
||||
final safeParams = params.replaceAll('"', '').replaceAll(' ', '+');
|
||||
final paramsJson =
|
||||
String.fromCharCodes(base64Decode(base64.normalize(safeParams)));
|
||||
return jsonDecode(paramsJson);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dart_ipc/dart_ipc.dart';
|
||||
|
||||
import '../../util/util.dart';
|
||||
|
||||
class RpcContext {
|
||||
final HttpRequest request;
|
||||
final HttpResponse response;
|
||||
String? _bodyCache;
|
||||
|
||||
RpcContext(this.request, this.response);
|
||||
|
||||
Future<Map<String, dynamic>> readJSON() async {
|
||||
if (_bodyCache == null) {
|
||||
final content = <int>[];
|
||||
await for (final data in request) {
|
||||
content.addAll(data);
|
||||
}
|
||||
_bodyCache = String.fromCharCodes(content);
|
||||
}
|
||||
|
||||
if (_bodyCache!.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return jsonDecode(_bodyCache!) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<String> readText() async {
|
||||
if (_bodyCache == null) {
|
||||
final content = <int>[];
|
||||
await for (final data in request) {
|
||||
content.addAll(data);
|
||||
}
|
||||
_bodyCache = String.fromCharCodes(content);
|
||||
}
|
||||
|
||||
return _bodyCache ?? '';
|
||||
}
|
||||
|
||||
Future<void> writeJSON(Map<String, dynamic> data) async {
|
||||
response.headers.contentType = ContentType.json;
|
||||
response.write(jsonEncode(data));
|
||||
}
|
||||
|
||||
Future<void> writeError(String message, [int statusCode = 500]) async {
|
||||
response.statusCode = statusCode;
|
||||
await writeJSON({'error': message});
|
||||
}
|
||||
}
|
||||
|
||||
typedef RouteHandler = Future<void> Function(RpcContext ctx);
|
||||
|
||||
class RouteRegistry {
|
||||
final Map<String, RouteHandler> _routes = {};
|
||||
|
||||
void register(String path, RouteHandler handler) {
|
||||
_routes[path] = handler;
|
||||
}
|
||||
|
||||
RouteHandler? getHandler(String path) {
|
||||
return _routes[path];
|
||||
}
|
||||
}
|
||||
|
||||
class RpcBinding {
|
||||
final String network;
|
||||
final String address;
|
||||
|
||||
const RpcBinding({
|
||||
required this.network,
|
||||
required this.address,
|
||||
});
|
||||
}
|
||||
|
||||
class RpcServerHandle {
|
||||
final RpcBinding binding;
|
||||
final HttpServer server;
|
||||
final Future<void> Function()? _cleanup;
|
||||
|
||||
RpcServerHandle({
|
||||
required this.binding,
|
||||
required this.server,
|
||||
Future<void> Function()? cleanup,
|
||||
}) : _cleanup = cleanup;
|
||||
|
||||
Future<void> close({bool force = true}) async {
|
||||
await server.close(force: force);
|
||||
if (_cleanup != null) {
|
||||
await _cleanup!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<RpcBinding> defaultHostRpcBinding() async {
|
||||
if (Util.isWindows()) {
|
||||
return const RpcBinding(
|
||||
network: 'pipe',
|
||||
address: r'\\.\pipe\gopeed_host',
|
||||
);
|
||||
}
|
||||
return RpcBinding(
|
||||
network: 'unix',
|
||||
address: await Util.homePathJoin('gopeed_host.sock'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<RpcBinding> defaultWebViewRpcBinding() async {
|
||||
if (Util.supportUnixSocket()) {
|
||||
return RpcBinding(
|
||||
network: 'unix',
|
||||
address: await Util.homePathJoin('gopeed_webview.sock'),
|
||||
);
|
||||
}
|
||||
return const RpcBinding(
|
||||
network: 'tcp',
|
||||
address: '127.0.0.1:0',
|
||||
);
|
||||
}
|
||||
|
||||
Future<RpcServerHandle> startRpcServer({
|
||||
RpcBinding? binding,
|
||||
Map<String, RouteHandler>? routes,
|
||||
}) async {
|
||||
final rpcBinding = binding ?? await defaultHostRpcBinding();
|
||||
Future<void> Function()? cleanup;
|
||||
late final HttpServer httpServer;
|
||||
|
||||
switch (rpcBinding.network) {
|
||||
case 'pipe':
|
||||
final serverSocket = await bind(rpcBinding.address);
|
||||
httpServer = HttpServer.listenOn(serverSocket);
|
||||
break;
|
||||
case 'unix':
|
||||
final socketFile = File(rpcBinding.address);
|
||||
if (await socketFile.exists()) {
|
||||
try {
|
||||
await socketFile.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
final serverSocket = await bind(rpcBinding.address);
|
||||
httpServer = HttpServer.listenOn(serverSocket);
|
||||
cleanup = () async {
|
||||
if (await socketFile.exists()) {
|
||||
try {
|
||||
await socketFile.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
};
|
||||
break;
|
||||
case 'tcp':
|
||||
final separator = rpcBinding.address.lastIndexOf(':');
|
||||
if (separator <= 0 || separator == rpcBinding.address.length - 1) {
|
||||
throw ArgumentError('invalid tcp rpc address: ${rpcBinding.address}');
|
||||
}
|
||||
final host = rpcBinding.address.substring(0, separator);
|
||||
final port = int.parse(rpcBinding.address.substring(separator + 1));
|
||||
httpServer = await HttpServer.bind(host, port);
|
||||
break;
|
||||
default:
|
||||
throw ArgumentError('unsupported rpc network: ${rpcBinding.network}');
|
||||
}
|
||||
|
||||
final registry = RouteRegistry();
|
||||
routes?.forEach(registry.register);
|
||||
|
||||
httpServer.forEach((request) async {
|
||||
final ctx = RpcContext(request, request.response);
|
||||
try {
|
||||
final handler = registry.getHandler(request.uri.path);
|
||||
if (handler != null) {
|
||||
await handler(ctx);
|
||||
} else {
|
||||
await ctx.writeError('Route not found: ${request.uri.path}', 404);
|
||||
}
|
||||
} catch (e) {
|
||||
await ctx.writeError('Internal server error: $e', 500);
|
||||
} finally {
|
||||
await request.response.close();
|
||||
}
|
||||
});
|
||||
|
||||
var resolvedBinding = rpcBinding;
|
||||
if (rpcBinding.network == 'tcp') {
|
||||
resolvedBinding = RpcBinding(
|
||||
network: rpcBinding.network,
|
||||
address: '${httpServer.address.address}:${httpServer.port}',
|
||||
);
|
||||
}
|
||||
|
||||
return RpcServerHandle(
|
||||
binding: resolvedBinding,
|
||||
server: httpServer,
|
||||
cleanup: cleanup,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'webview_rpc_service.dart';
|
||||
|
||||
class WebViewRpcOverlay extends StatefulWidget {
|
||||
const WebViewRpcOverlay({super.key});
|
||||
|
||||
@override
|
||||
State<WebViewRpcOverlay> createState() => _WebViewRpcOverlayState();
|
||||
}
|
||||
|
||||
class _WebViewRpcOverlayState extends State<WebViewRpcOverlay> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
WebViewRpcService.instance.markOverlayReady();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<List<WebViewRpcPageSession>>(
|
||||
valueListenable: WebViewRpcService.instance.pages,
|
||||
builder: (context, pages, _) {
|
||||
if (pages.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return IgnorePointer(
|
||||
ignoring: false,
|
||||
child: Stack(
|
||||
children: pages
|
||||
.map((page) => _WebViewRpcPageView(page: page))
|
||||
.toList(growable: false),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WebViewRpcPageView extends StatefulWidget {
|
||||
const _WebViewRpcPageView({required this.page});
|
||||
|
||||
final WebViewRpcPageSession page;
|
||||
|
||||
@override
|
||||
State<_WebViewRpcPageView> createState() => _WebViewRpcPageViewState();
|
||||
}
|
||||
|
||||
class _WebViewRpcPageViewState extends State<_WebViewRpcPageView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final page = widget.page;
|
||||
final content = page.buildWebView();
|
||||
|
||||
return Positioned.fill(
|
||||
child: Material(
|
||||
color: Colors.black54,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: page.width > 0 ? page.width.toDouble() : 960,
|
||||
height: page.height > 0 ? page.height.toDouble() : 720,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 24,
|
||||
color: Colors.black26,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
alignment: Alignment.centerLeft,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Text(
|
||||
page.title.isNotEmpty ? page.title : 'WebView',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
Expanded(child: content),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
import '../../core/common/start_config.dart';
|
||||
import '../../util/log_util.dart';
|
||||
import '../../util/util.dart';
|
||||
import 'server.dart';
|
||||
|
||||
String buildWebViewExecuteScript({
|
||||
required String channelName,
|
||||
required String requestId,
|
||||
required String expression,
|
||||
required List<dynamic> args,
|
||||
}) {
|
||||
return '''
|
||||
(() => {
|
||||
var __bridge = window.flutter_inappwebview;
|
||||
var __id = ${jsonEncode(requestId)};
|
||||
var __expr = ${jsonEncode(expression)};
|
||||
var __args = ${jsonEncode(args)};
|
||||
var __serializeError = function(error) {
|
||||
return [
|
||||
error && error.name ? error.name : 'Error',
|
||||
error && error.message ? error.message : String(error),
|
||||
error && error.stack ? error.stack : ''
|
||||
].filter(Boolean).join(': ').replace(': @', '\\n@');
|
||||
};
|
||||
var __post = function(message) {
|
||||
if (__bridge && typeof __bridge.callHandler === 'function') {
|
||||
__bridge.callHandler(${jsonEncode(channelName)}, JSON.stringify(message));
|
||||
}
|
||||
};
|
||||
try {
|
||||
Promise.resolve()
|
||||
.then(function() {
|
||||
var __target = (0, eval)(__expr);
|
||||
if (typeof __target === 'function') {
|
||||
return __target.apply(null, __args || []);
|
||||
}
|
||||
return __target;
|
||||
})
|
||||
.then(function(__value) {
|
||||
__post({ id: __id, ok: true, value: __value == null ? null : __value });
|
||||
})
|
||||
.catch(function(error) {
|
||||
__post({
|
||||
id: __id,
|
||||
ok: false,
|
||||
error: __serializeError(error)
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
__post({
|
||||
id: __id,
|
||||
ok: false,
|
||||
error: __serializeError(error)
|
||||
});
|
||||
}
|
||||
})();
|
||||
''';
|
||||
}
|
||||
|
||||
class WebViewRpcService {
|
||||
WebViewRpcService._();
|
||||
|
||||
static final WebViewRpcService instance = WebViewRpcService._();
|
||||
|
||||
final ValueNotifier<List<WebViewRpcPageSession>> pages =
|
||||
ValueNotifier<List<WebViewRpcPageSession>>([]);
|
||||
|
||||
final Map<String, WebViewRpcPageSession> _pagesById = {};
|
||||
final Completer<void> _overlayReady = Completer<void>();
|
||||
final CookieManager _cookieManager = CookieManager.instance();
|
||||
|
||||
RpcServerHandle? _server;
|
||||
int _pageSeq = 0;
|
||||
|
||||
bool get supported => Util.isMobile() || Util.isMacos();
|
||||
|
||||
void markOverlayReady() {
|
||||
if (!_overlayReady.isCompleted) {
|
||||
_overlayReady.complete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<WebViewRpcConfig?> start() async {
|
||||
if (!supported) {
|
||||
return null;
|
||||
}
|
||||
if (_server != null) {
|
||||
return _toConfig(_server!.binding);
|
||||
}
|
||||
final binding = await defaultWebViewRpcBinding();
|
||||
_server = await startRpcServer(
|
||||
binding: binding,
|
||||
routes: {
|
||||
'/webview': _handleWebViewRequest,
|
||||
},
|
||||
);
|
||||
return _toConfig(_server!.binding);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
for (final page in _pagesById.values.toList()) {
|
||||
await page.dispose();
|
||||
}
|
||||
_pagesById.clear();
|
||||
pages.value = const [];
|
||||
final server = _server;
|
||||
_server = null;
|
||||
if (server != null) {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleWebViewRequest(RpcContext ctx) async {
|
||||
await _overlayReady.future;
|
||||
final body = await ctx.readJSON();
|
||||
final method = body['method'] as String? ?? '';
|
||||
final params = (body['params'] as Map?)?.cast<String, dynamic>() ??
|
||||
const <String, dynamic>{};
|
||||
try {
|
||||
final result = await _dispatch(method, params);
|
||||
await ctx.writeJSON({
|
||||
'result': result,
|
||||
'error': null,
|
||||
});
|
||||
} catch (e, stackTrace) {
|
||||
logger.w('webview rpc request failed: $method', e, stackTrace);
|
||||
await ctx.writeJSON({
|
||||
'result': null,
|
||||
'error': _toRpcError(method, e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> _dispatch(String method, Map<String, dynamic> params) async {
|
||||
switch (method) {
|
||||
case 'webview.isAvailable':
|
||||
return {'available': true};
|
||||
case 'page.open':
|
||||
return _openPage(params);
|
||||
case 'page.addInitScript':
|
||||
return _page(params).addInitScript(_string(params, 'script'));
|
||||
case 'page.goto':
|
||||
await _page(params).goto(
|
||||
_string(params, 'url'),
|
||||
timeoutMs: _int(params, 'timeoutMs'),
|
||||
waitUntil: _stringOrNull(params, 'waitUntil'),
|
||||
);
|
||||
return {};
|
||||
case 'page.execute':
|
||||
return _page(params).execute(
|
||||
_string(params, 'expression'),
|
||||
(_list(params, 'args') ?? const []),
|
||||
);
|
||||
case 'page.getCookies':
|
||||
return _page(params).getCookies();
|
||||
case 'page.setCookie':
|
||||
await _page(params).setCookie(_cookie(params));
|
||||
return {};
|
||||
case 'page.deleteCookie':
|
||||
await _page(params).deleteCookie(_cookie(params));
|
||||
return {};
|
||||
case 'page.clearCookies':
|
||||
await _page(params).clearCookies();
|
||||
return {};
|
||||
case 'page.close':
|
||||
await _closePage(_pageId(params));
|
||||
return {};
|
||||
default:
|
||||
throw WebViewRpcException(
|
||||
code: 'UNKNOWN_METHOD',
|
||||
message: 'unknown method: $method',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _openPage(Map<String, dynamic> params) async {
|
||||
final pageId = 'page-${++_pageSeq}';
|
||||
final session = WebViewRpcPageSession(
|
||||
pageId: pageId,
|
||||
cookieManager: _cookieManager,
|
||||
headless: params['headless'] as bool? ?? false,
|
||||
debug: params['debug'] as bool? ?? false,
|
||||
title: params['title'] as String? ?? '',
|
||||
width: (params['width'] as num?)?.toInt() ?? 1280,
|
||||
height: (params['height'] as num?)?.toInt() ?? 800,
|
||||
userAgent: params['userAgent'] as String? ?? '',
|
||||
);
|
||||
_pagesById[pageId] = session;
|
||||
_syncPages();
|
||||
try {
|
||||
await session.init();
|
||||
} catch (e) {
|
||||
_pagesById.remove(pageId);
|
||||
_syncPages();
|
||||
rethrow;
|
||||
}
|
||||
return {'pageId': pageId};
|
||||
}
|
||||
|
||||
WebViewRpcPageSession _page(Map<String, dynamic> params) {
|
||||
final page = _pagesById[_pageId(params)];
|
||||
if (page == null) {
|
||||
throw WebViewRpcException(
|
||||
code: 'PAGE_NOT_FOUND',
|
||||
message: 'page not found',
|
||||
);
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
String _pageId(Map<String, dynamic> params) => _string(params, 'pageId');
|
||||
|
||||
String _string(Map<String, dynamic> params, String key) {
|
||||
final value = params[key];
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
throw WebViewRpcException(
|
||||
code: 'INVALID_REQUEST',
|
||||
message: 'missing or invalid "$key"',
|
||||
);
|
||||
}
|
||||
|
||||
String? _stringOrNull(Map<String, dynamic> params, String key) {
|
||||
final value = params[key];
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
throw WebViewRpcException(
|
||||
code: 'INVALID_REQUEST',
|
||||
message: 'invalid "$key"',
|
||||
);
|
||||
}
|
||||
|
||||
int? _int(Map<String, dynamic> params, String key) {
|
||||
final value = params[key];
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.toInt();
|
||||
}
|
||||
throw WebViewRpcException(
|
||||
code: 'INVALID_REQUEST',
|
||||
message: 'invalid "$key"',
|
||||
);
|
||||
}
|
||||
|
||||
List<dynamic>? _list(Map<String, dynamic> params, String key) {
|
||||
final value = params[key];
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is List<dynamic>) {
|
||||
return value;
|
||||
}
|
||||
throw WebViewRpcException(
|
||||
code: 'INVALID_REQUEST',
|
||||
message: 'invalid "$key"',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _cookie(Map<String, dynamic> params) {
|
||||
final value = params['cookie'];
|
||||
if (value is Map<String, dynamic>) {
|
||||
return value;
|
||||
}
|
||||
if (value is Map) {
|
||||
return value.cast<String, dynamic>();
|
||||
}
|
||||
throw WebViewRpcException(
|
||||
code: 'INVALID_REQUEST',
|
||||
message: 'missing or invalid "cookie"',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _closePage(String pageId) async {
|
||||
final page = _pagesById.remove(pageId);
|
||||
if (page == null) {
|
||||
throw WebViewRpcException(
|
||||
code: 'PAGE_NOT_FOUND',
|
||||
message: 'page not found',
|
||||
);
|
||||
}
|
||||
_syncPages();
|
||||
await page.dispose();
|
||||
}
|
||||
|
||||
void _syncPages() {
|
||||
pages.value = _pagesById.values
|
||||
.where((page) => !page.headless)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
WebViewRpcConfig _toConfig(RpcBinding binding) {
|
||||
final config = WebViewRpcConfig()
|
||||
..network = binding.network
|
||||
..address = binding.address;
|
||||
return config;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _toRpcError(String method, Object error) {
|
||||
if (error is WebViewRpcException) {
|
||||
return {
|
||||
'code': error.code,
|
||||
'message': error.message,
|
||||
};
|
||||
}
|
||||
final code = switch (method) {
|
||||
'page.goto' => 'NAVIGATION_FAILED',
|
||||
'page.execute' => 'EVALUATION_FAILED',
|
||||
_ => 'INTERNAL_ERROR',
|
||||
};
|
||||
return {
|
||||
'code': code,
|
||||
'message': error.toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class WebViewRpcPageSession {
|
||||
WebViewRpcPageSession({
|
||||
required this.pageId,
|
||||
required this.cookieManager,
|
||||
required this.headless,
|
||||
required this.debug,
|
||||
required this.title,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.userAgent,
|
||||
});
|
||||
|
||||
final String pageId;
|
||||
final CookieManager cookieManager;
|
||||
final bool headless;
|
||||
final bool debug;
|
||||
final String title;
|
||||
final int width;
|
||||
final int height;
|
||||
final String userAgent;
|
||||
late final String callbackChannelName;
|
||||
|
||||
final List<UserScript> _initScripts = [];
|
||||
final Map<String, Completer<dynamic>> _pendingExecutions =
|
||||
<String, Completer<dynamic>>{};
|
||||
|
||||
final Completer<void> _ready = Completer<void>();
|
||||
HeadlessInAppWebView? _headlessWebView;
|
||||
InAppWebViewController? _controller;
|
||||
Completer<void>? _navigation;
|
||||
String _currentUrl = '';
|
||||
int _executeSeq = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
Future<void> init() async {
|
||||
callbackChannelName =
|
||||
'__gopeedWebViewCallback_${pageId.replaceAll('-', '_')}';
|
||||
if (headless) {
|
||||
_headlessWebView = HeadlessInAppWebView(
|
||||
initialSize: Size(
|
||||
width > 0 ? width.toDouble() : 1,
|
||||
height > 0 ? height.toDouble() : 1,
|
||||
),
|
||||
initialSettings: _settings,
|
||||
initialUserScripts: UnmodifiableListView(_initScripts),
|
||||
onWebViewCreated: _attachController,
|
||||
onLoadStart: _handleLoadStart,
|
||||
onLoadStop: _handleLoadStop,
|
||||
onReceivedError: _handleReceivedError,
|
||||
onReceivedHttpError: _handleReceivedHttpError,
|
||||
);
|
||||
await _headlessWebView!.run();
|
||||
}
|
||||
await _ready.future;
|
||||
}
|
||||
|
||||
Future<void> addInitScript(String script) async {
|
||||
final userScript = UserScript(
|
||||
source: script,
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
);
|
||||
_initScripts.add(userScript);
|
||||
final controller = await _controllerOrThrow();
|
||||
await controller.addUserScript(userScript: userScript);
|
||||
if (_currentUrl.isNotEmpty && !_disposed) {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: script);
|
||||
} catch (_) {
|
||||
// Best-effort only for the current page. The user script persists
|
||||
// for future navigations through the native user script registry.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> goto(String url, {int? timeoutMs, String? waitUntil}) async {
|
||||
final controller = await _controllerOrThrow();
|
||||
final waitStrategy = _normalizeWaitUntil(waitUntil);
|
||||
final navigation = _ensureNavigationCompleter();
|
||||
try {
|
||||
await controller.loadUrl(urlRequest: URLRequest(url: WebUri(url)));
|
||||
} catch (e) {
|
||||
if (identical(_navigation, navigation)) {
|
||||
_navigation = null;
|
||||
}
|
||||
throw WebViewRpcException(
|
||||
code: 'NAVIGATION_FAILED',
|
||||
message: e.toString(),
|
||||
);
|
||||
}
|
||||
final future = switch (waitStrategy) {
|
||||
'domcontentloaded' => Future.any<void>([
|
||||
_waitForDomReady(),
|
||||
navigation.future,
|
||||
]),
|
||||
_ => navigation.future,
|
||||
};
|
||||
if (timeoutMs == null || timeoutMs <= 0) {
|
||||
await future;
|
||||
return;
|
||||
}
|
||||
await future.timeout(
|
||||
Duration(milliseconds: timeoutMs),
|
||||
onTimeout: () {
|
||||
_navigation = null;
|
||||
throw WebViewRpcException(
|
||||
code: 'TIMEOUT',
|
||||
message: 'navigation timeout after ${timeoutMs}ms',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> execute(String expression, List<dynamic> args) async {
|
||||
final controller = await _controllerOrThrow();
|
||||
final requestId = 'exec-$pageId-${++_executeSeq}';
|
||||
final completer = Completer<dynamic>();
|
||||
_pendingExecutions[requestId] = completer;
|
||||
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
source: buildWebViewExecuteScript(
|
||||
channelName: callbackChannelName,
|
||||
requestId: requestId,
|
||||
expression: expression,
|
||||
args: args,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
_pendingExecutions.remove(requestId);
|
||||
throw WebViewRpcException(
|
||||
code: 'EVALUATION_FAILED',
|
||||
message: e.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final result = await completer.future.timeout(
|
||||
const Duration(seconds: 30),
|
||||
onTimeout: () {
|
||||
_pendingExecutions.remove(requestId);
|
||||
throw WebViewRpcException(
|
||||
code: 'TIMEOUT',
|
||||
message: 'javascript execution timeout',
|
||||
);
|
||||
},
|
||||
);
|
||||
return result;
|
||||
} finally {
|
||||
_pendingExecutions.remove(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
String _normalizeWaitUntil(String? waitUntil) {
|
||||
if (waitUntil == null || waitUntil.isEmpty) {
|
||||
return 'load';
|
||||
}
|
||||
switch (waitUntil.toLowerCase()) {
|
||||
case 'load':
|
||||
return 'load';
|
||||
case 'domcontentloaded':
|
||||
return 'domcontentloaded';
|
||||
default:
|
||||
throw WebViewRpcException(
|
||||
code: 'INVALID_REQUEST',
|
||||
message: 'invalid "waitUntil"',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _waitForDomReady() async {
|
||||
final controller = await _controllerOrThrow();
|
||||
while (true) {
|
||||
final readyState = await controller.evaluateJavascript(
|
||||
source: 'document.readyState',
|
||||
);
|
||||
final state = readyState?.toString() ?? '';
|
||||
if (state == 'interactive' || state == 'complete') {
|
||||
return;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getCookies() async {
|
||||
if (_currentUrl.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
final cookies = await cookieManager.getCookies(url: WebUri(_currentUrl));
|
||||
return cookies.map((cookie) {
|
||||
return <String, dynamic>{
|
||||
'name': cookie.name,
|
||||
'value': cookie.value,
|
||||
'domain': cookie.domain ?? '',
|
||||
'path': cookie.path ?? '/',
|
||||
'secure': cookie.isSecure,
|
||||
'httpOnly': cookie.isHttpOnly,
|
||||
};
|
||||
}).toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> setCookie(Map<String, dynamic> cookie) async {
|
||||
final targetUrl = _cookieUrl(cookie);
|
||||
await cookieManager.setCookie(
|
||||
url: targetUrl,
|
||||
name: cookie['name'] as String? ?? '',
|
||||
value: cookie['value'] as String? ?? '',
|
||||
domain: cookie['domain'] as String?,
|
||||
path: cookie['path'] as String? ?? '/',
|
||||
expiresDate: _cookieExpires(cookie),
|
||||
isSecure: cookie['secure'] as bool? ?? false,
|
||||
isHttpOnly: cookie['httpOnly'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteCookie(Map<String, dynamic> cookie) async {
|
||||
final name = cookie['name'] as String? ?? '';
|
||||
final path = cookie['path'] as String? ?? '/';
|
||||
final domain = cookie['domain'] as String? ?? '';
|
||||
if (name.isEmpty) {
|
||||
throw WebViewRpcException(
|
||||
code: 'INVALID_REQUEST',
|
||||
message: 'cookie.name is required',
|
||||
);
|
||||
}
|
||||
await cookieManager.deleteCookie(
|
||||
url: _cookieUrl(cookie),
|
||||
name: name,
|
||||
domain: domain.isNotEmpty ? domain : null,
|
||||
path: path,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearCookies() async {
|
||||
await cookieManager.deleteAllCookies();
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_disposed = true;
|
||||
if (!_ready.isCompleted) {
|
||||
_ready.completeError(
|
||||
WebViewRpcException(
|
||||
code: 'PAGE_NOT_FOUND',
|
||||
message: 'webview page closed',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_navigation != null) {
|
||||
_completeNavigationError(
|
||||
WebViewRpcException(
|
||||
code: 'NAVIGATION_FAILED',
|
||||
message: 'webview page closed',
|
||||
),
|
||||
);
|
||||
}
|
||||
for (final completer in _pendingExecutions.values) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(
|
||||
WebViewRpcException(
|
||||
code: 'EVALUATION_FAILED',
|
||||
message: 'webview page closed',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
_pendingExecutions.clear();
|
||||
await Future.sync(() => _headlessWebView?.dispose());
|
||||
_headlessWebView = null;
|
||||
_controller = null;
|
||||
}
|
||||
|
||||
Completer<void> _ensureNavigationCompleter() {
|
||||
return _navigation ??= Completer<void>();
|
||||
}
|
||||
|
||||
Widget buildWebView() {
|
||||
return InAppWebView(
|
||||
key: ValueKey<String>('webview-$pageId'),
|
||||
initialSettings: _settings,
|
||||
initialUserScripts: UnmodifiableListView(_initScripts),
|
||||
onWebViewCreated: _attachController,
|
||||
onLoadStart: _handleLoadStart,
|
||||
onLoadStop: _handleLoadStop,
|
||||
onReceivedError: _handleReceivedError,
|
||||
onReceivedHttpError: _handleReceivedHttpError,
|
||||
);
|
||||
}
|
||||
|
||||
InAppWebViewSettings get _settings => InAppWebViewSettings(
|
||||
javaScriptEnabled: true,
|
||||
transparentBackground: true,
|
||||
isInspectable: debug,
|
||||
userAgent: userAgent.isNotEmpty ? userAgent : null,
|
||||
);
|
||||
|
||||
Future<InAppWebViewController> _controllerOrThrow() async {
|
||||
await _ready.future;
|
||||
final controller = _controller;
|
||||
if (controller != null && !_disposed) {
|
||||
return controller;
|
||||
}
|
||||
throw WebViewRpcException(
|
||||
code: 'PAGE_NOT_FOUND',
|
||||
message: 'page not found',
|
||||
);
|
||||
}
|
||||
|
||||
void _attachController(InAppWebViewController controller) {
|
||||
_controller = controller;
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: callbackChannelName,
|
||||
callback: (args) {
|
||||
if (args.isNotEmpty) {
|
||||
_handleExecuteMessage(args.first?.toString() ?? '');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
if (!_ready.isCompleted) {
|
||||
_ready.complete();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleLoadStart(InAppWebViewController controller, WebUri? url) {
|
||||
_currentUrl = url?.toString() ?? _currentUrl;
|
||||
_ensureNavigationCompleter();
|
||||
}
|
||||
|
||||
Future<void> _handleLoadStop(
|
||||
InAppWebViewController controller,
|
||||
WebUri? url,
|
||||
) async {
|
||||
_currentUrl = url?.toString() ?? _currentUrl;
|
||||
_completeNavigation();
|
||||
}
|
||||
|
||||
void _handleReceivedError(
|
||||
InAppWebViewController controller,
|
||||
WebResourceRequest request,
|
||||
WebResourceError error,
|
||||
) {
|
||||
_completeNavigationError(
|
||||
WebViewRpcException(
|
||||
code: 'NAVIGATION_FAILED',
|
||||
message: error.description,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleReceivedHttpError(
|
||||
InAppWebViewController controller,
|
||||
WebResourceRequest request,
|
||||
WebResourceResponse errorResponse,
|
||||
) {
|
||||
_completeNavigationError(
|
||||
WebViewRpcException(
|
||||
code: 'NAVIGATION_FAILED',
|
||||
message:
|
||||
'HTTP ${errorResponse.statusCode}: ${errorResponse.reasonPhrase ?? 'navigation failed'}',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _completeNavigation() {
|
||||
if (_navigation != null && !_navigation!.isCompleted) {
|
||||
_navigation!.complete();
|
||||
}
|
||||
_navigation = null;
|
||||
}
|
||||
|
||||
void _completeNavigationError(Object error) {
|
||||
if (_navigation != null && !_navigation!.isCompleted) {
|
||||
_navigation!.completeError(error);
|
||||
}
|
||||
_navigation = null;
|
||||
}
|
||||
|
||||
WebUri _cookieUrl(Map<String, dynamic> cookie) {
|
||||
final domain = (cookie['domain'] as String? ?? '').trim();
|
||||
final path = cookie['path'] as String? ?? '/';
|
||||
if (domain.isNotEmpty) {
|
||||
final host = domain.startsWith('.') ? domain.substring(1) : domain;
|
||||
final scheme = (cookie['secure'] as bool? ?? false) ? 'https' : 'http';
|
||||
return WebUri('$scheme://$host$path');
|
||||
}
|
||||
if (_currentUrl.isNotEmpty) {
|
||||
return WebUri(_currentUrl);
|
||||
}
|
||||
throw WebViewRpcException(
|
||||
code: 'INVALID_REQUEST',
|
||||
message: 'cookie target url is unavailable',
|
||||
);
|
||||
}
|
||||
|
||||
int? _cookieExpires(Map<String, dynamic> cookie) {
|
||||
final raw = cookie['expires'];
|
||||
if (raw is int) {
|
||||
return raw;
|
||||
}
|
||||
if (raw is String && raw.isNotEmpty) {
|
||||
return DateTime.tryParse(raw)?.millisecondsSinceEpoch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _handleExecuteMessage(String payload) {
|
||||
if (payload.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final decoded = jsonDecode(payload);
|
||||
if (decoded is! Map) {
|
||||
return;
|
||||
}
|
||||
final message = decoded.cast<String, dynamic>();
|
||||
final id = message['id']?.toString();
|
||||
if (id == null || id.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final completer = _pendingExecutions[id];
|
||||
if (completer == null || completer.isCompleted) {
|
||||
return;
|
||||
}
|
||||
if (message['ok'] == true) {
|
||||
completer.complete(message['value']);
|
||||
return;
|
||||
}
|
||||
completer.completeError(
|
||||
WebViewRpcException(
|
||||
code: 'EVALUATION_FAILED',
|
||||
message: message['error']?.toString() ?? 'javascript execution failed',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WebViewRpcException implements Exception {
|
||||
final String code;
|
||||
final String message;
|
||||
|
||||
WebViewRpcException({
|
||||
required this.code,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -119,7 +119,10 @@ class BuildTaskListView extends GetView {
|
||||
}
|
||||
|
||||
Future<void> showUpdateUrlDialog(BuildContext context, Task task) async {
|
||||
final urlController = TextEditingController(text: task.meta.req.url);
|
||||
final displayUrl = (task.meta.req.rawUrl?.isNotEmpty ?? false)
|
||||
? task.meta.req.rawUrl!
|
||||
: task.meta.req.url;
|
||||
final urlController = TextEditingController(text: displayUrl);
|
||||
final headerControllers =
|
||||
<MapEntry<TextEditingController, TextEditingController>>[];
|
||||
|
||||
|
||||
@@ -2,6 +2,20 @@ import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'start_config.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class WebViewRpcConfig {
|
||||
late String network;
|
||||
late String address;
|
||||
String? token;
|
||||
|
||||
WebViewRpcConfig();
|
||||
|
||||
factory WebViewRpcConfig.fromJson(Map<String, dynamic> json) =>
|
||||
_$WebViewRpcConfigFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$WebViewRpcConfigToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class StartConfig {
|
||||
late String network;
|
||||
@@ -10,6 +24,7 @@ class StartConfig {
|
||||
late String storageDir;
|
||||
late int refreshInterval;
|
||||
late String apiToken;
|
||||
WebViewRpcConfig? webViewRpcConfig;
|
||||
|
||||
StartConfig();
|
||||
|
||||
|
||||
@@ -6,13 +6,30 @@ part of 'start_config.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
WebViewRpcConfig _$WebViewRpcConfigFromJson(Map<String, dynamic> json) =>
|
||||
WebViewRpcConfig()
|
||||
..network = json['network'] as String
|
||||
..address = json['address'] as String
|
||||
..token = json['token'] as String?;
|
||||
|
||||
Map<String, dynamic> _$WebViewRpcConfigToJson(WebViewRpcConfig instance) =>
|
||||
<String, dynamic>{
|
||||
'network': instance.network,
|
||||
'address': instance.address,
|
||||
'token': instance.token,
|
||||
};
|
||||
|
||||
StartConfig _$StartConfigFromJson(Map<String, dynamic> json) => StartConfig()
|
||||
..network = json['network'] as String
|
||||
..address = json['address'] as String
|
||||
..storage = json['storage'] as String
|
||||
..storageDir = json['storageDir'] as String
|
||||
..refreshInterval = (json['refreshInterval'] as num).toInt()
|
||||
..apiToken = json['apiToken'] as String;
|
||||
..apiToken = json['apiToken'] as String
|
||||
..webViewRpcConfig = json['webViewRpcConfig'] == null
|
||||
? null
|
||||
: WebViewRpcConfig.fromJson(
|
||||
json['webViewRpcConfig'] as Map<String, dynamic>);
|
||||
|
||||
Map<String, dynamic> _$StartConfigToJson(StartConfig instance) =>
|
||||
<String, dynamic>{
|
||||
@@ -22,4 +39,5 @@ Map<String, dynamic> _$StartConfigToJson(StartConfig instance) =>
|
||||
'storageDir': instance.storageDir,
|
||||
'refreshInterval': instance.refreshInterval,
|
||||
'apiToken': instance.apiToken,
|
||||
'webViewRpcConfig': instance.webViewRpcConfig,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'api/api.dart' as api;
|
||||
import 'app/modules/app/controllers/app_controller.dart';
|
||||
import 'app/rpc/webview_rpc_service.dart';
|
||||
import 'app/modules/app/views/app_view.dart';
|
||||
import 'core/libgopeed_boot.dart';
|
||||
import 'database/database.dart';
|
||||
@@ -109,6 +110,10 @@ Future<void> init(StartupArgs args) async {
|
||||
try {
|
||||
await controller.loadStartConfig();
|
||||
final startCfg = controller.startConfig.value;
|
||||
final webViewRpcConfig = await WebViewRpcService.instance.start();
|
||||
if (webViewRpcConfig != null) {
|
||||
startCfg.webViewRpcConfig = webViewRpcConfig;
|
||||
}
|
||||
controller.runningPort.value = await LibgopeedBoot.instance.start(startCfg);
|
||||
api.init(startCfg.network, controller.runningAddress(), startCfg.apiToken);
|
||||
} catch (e) {
|
||||
|
||||
@@ -5,6 +5,11 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- device_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- flutter_inappwebview_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- OrderedSet (~> 6.0.3)
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
- HotKey (0.2.1)
|
||||
- hotkey_manager_macos (0.0.1):
|
||||
@@ -12,6 +17,7 @@ PODS:
|
||||
- HotKey
|
||||
- open_dir_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- OrderedSet (6.0.3)
|
||||
- package_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- path_provider_foundation (0.0.1):
|
||||
@@ -35,6 +41,8 @@ DEPENDENCIES:
|
||||
- app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`)
|
||||
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
|
||||
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
|
||||
- flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`)
|
||||
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
|
||||
- open_dir_macos (from `Flutter/ephemeral/.symlinks/plugins/open_dir_macos/macos`)
|
||||
@@ -50,6 +58,7 @@ DEPENDENCIES:
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- HotKey
|
||||
- OrderedSet
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
app_links:
|
||||
@@ -58,6 +67,10 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos
|
||||
device_info_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
|
||||
flutter_inappwebview_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos
|
||||
flutter_local_notifications:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
hotkey_manager_macos:
|
||||
@@ -85,10 +98,13 @@ SPEC CHECKSUMS:
|
||||
app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468
|
||||
desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43
|
||||
device_info_plus: 724ebb83a99d517430554a1810445ff28fe71fa3
|
||||
flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d
|
||||
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
|
||||
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
|
||||
open_dir_macos: 79810d7921d777f8732bf6358b3a0839a8fad9b5
|
||||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||
package_info_plus: 8147fd5037235202932580f35ebe195f7a55e3cd
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
screen_retriever: 4f97c103641aab8ce183fa5af3b87029df167936
|
||||
|
||||
@@ -398,6 +398,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.2.0"
|
||||
flutter_inappwebview:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_inappwebview
|
||||
sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5"
|
||||
flutter_inappwebview_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_android
|
||||
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
flutter_inappwebview_internal_annotations:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_internal_annotations
|
||||
sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
flutter_inappwebview_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_ios
|
||||
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_macos
|
||||
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_platform_interface
|
||||
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0+1"
|
||||
flutter_inappwebview_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_web
|
||||
sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_windows
|
||||
sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
||||
@@ -83,6 +83,7 @@ dependencies:
|
||||
flutter_svg: ^2.0.17
|
||||
flutter_markdown_plus: ^1.0.3
|
||||
dart_ipc: ^1.0.1
|
||||
flutter_inappwebview: ^6.1.5
|
||||
flutter_local_notifications: 20.1.0
|
||||
dependency_overrides:
|
||||
permission_handler_windows:
|
||||
|
||||
Reference in New Issue
Block a user