feat: add WebView support for extension (#1359)

This commit is contained in:
Levi
2026-04-24 22:16:33 +08:00
committed by GitHub
parent a205c0b8d7
commit def54f8998
92 changed files with 10248 additions and 2676 deletions
+12 -10
View File
@@ -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
+2 -2
View File
@@ -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
+65 -9
View File
@@ -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:
@@ -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
@@ -142,7 +198,7 @@ jobs:
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
@@ -174,7 +230,7 @@ jobs:
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
@@ -195,7 +251,7 @@ jobs:
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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -182,7 +182,7 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
### 🌍 環境
1. Go 言語 1.24+
1. Go 言語 1.25+
2. Flutter 3.38+
### 📋 クローン
+1 -1
View File
@@ -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
View File
@@ -192,7 +192,7 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、
### 🌍 环境要求
1. Golang 1.24+
1. Golang 1.25+
2. Flutter 3.38+
### 📋 克隆项目
+1 -1
View File
@@ -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+
### 📋 克隆項目
+1
View File
@@ -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())
+23
View File
@@ -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()
}
+15
View File
@@ -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()
}
+9
View File
@@ -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
View File
@@ -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 {
+3 -4
View File
@@ -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
+6 -2
View File
@@ -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=
+75 -23
View File
@@ -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
+89 -51
View File
@@ -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.
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
if f.meta.Res.Size > 0 && targetOffset < f.meta.Res.Size {
finalErr = io.ErrUnexpectedEOF
return
}
select {
case f.doneCh <- nil:
default:
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
}
}
+122
View File
@@ -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))
}
}
+174 -4
View File
@@ -42,8 +42,18 @@ type SessionRef interface {
Release()
}
type ResumeFunc func(offset int64) error
type CreateWritableStreamOptions struct {
Session SessionRef
Reopenable bool
}
type Snapshot struct {
Written int64
DeclaredSize int64
Range bool
Started bool
State SourceState
Err error
WaitCh <-chan struct{}
@@ -66,6 +76,11 @@ 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 {
@@ -73,6 +88,9 @@ func (s *Source) Snapshot() Snapshot {
defer s.mu.Unlock()
return Snapshot{
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
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
}
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
}
@@ -284,6 +332,10 @@ func (r *Registry) createSource(sourceType SourceType, contentType string, sessi
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
}
+46
View File
@@ -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)
}
}
+3 -3
View File
@@ -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()
+9 -16
View File
@@ -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,11 +701,14 @@ func rangeFileHandle(writer http.ResponseWriter, request *http.Request, modifyEn
writer.WriteHeader(400)
return
}
end, err := strconv.ParseInt(s[1], 10, 64)
end := int64(BuildSize - 1)
if s[1] != "" {
end, err = strconv.ParseInt(s[1], 10, 64)
if err != nil {
writer.WriteHeader(400)
return
}
}
if start < 0 || end < 0 || start > end {
writer.WriteHeader(400)
return
@@ -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
}
+608
View File
@@ -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
}
+488
View File
@@ -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.
+195
View File
@@ -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)
}
}
+47 -8
View File
@@ -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 {
+63 -13
View File
@@ -21,13 +21,15 @@ import (
"github.com/GopeedLab/gopeed/pkg/util"
)
var testDownloadOpt = &base.Options{
func newTestDownloadOpt() *base.Options {
return &base.Options{
Path: test.Dir,
Name: test.DownloadName,
Extra: http.OptsExtra{
Connections: 4,
},
}
}
func TestDownloader_Resolve(t *testing.T) {
listener := test.StartTestFileServer()
@@ -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)
}
+101 -37
View File
@@ -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)
func exportJSValue(value goja.Value) any {
if value == nil {
return nil
}
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 {
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 nil, errors.New(stack)
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 export, nil
}()
return fn(goja.FunctionCall{
This: nil,
Arguments: args,
}), nil
}
+163
View File
@@ -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")
@@ -43,6 +47,83 @@ func TestError(t *testing.T) {
}
}
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)
+422 -16
View File
@@ -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()
}
+556 -23
View File
@@ -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 || "");
}
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)));
const described = describeObjectURLValue(value);
if (described.kind === "blob") {
return createBlobObjectURL(described.value._buffer, described.value.type || "");
}
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;
}
})();
+8 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -18,7 +18,9 @@
"webpack-cli": "^5.0.1"
},
"dependencies": {
"abortcontroller-polyfill": "^1.7.8",
"blob-polyfill": "^7.0.20220408",
"event-target-shim": "^6.0.2",
"fastestsmallesttextencoderdecoder": "^1.0.22",
"whatwg-fetch": "^3.6.20"
}
@@ -0,0 +1,2 @@
import "../event/index.js";
import "abortcontroller-polyfill/dist/abortcontroller-polyfill-only.js";
@@ -0,0 +1,9 @@
import { Event, EventTarget } from "event-target-shim";
if (typeof globalThis.Event !== "function") {
globalThis.Event = Event;
}
if (typeof globalThis.EventTarget !== "function") {
globalThis.EventTarget = EventTarget;
}
@@ -1,3 +1,5 @@
import "./event/index.js"
import "./abort/index.js"
import "./blob/index.js"
import "./crypto/index.js"
// polyfill TextEncoder
+20
View File
@@ -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
}
+143
View File
@@ -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{}
+765
View File
@@ -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{}
}
+338
View File
@@ -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
}
+31 -7
View File
@@ -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
@@ -608,27 +627,32 @@ type Instance struct {
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)
}
+75
View File
@@ -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
}
+58 -2
View File
@@ -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
}
+258
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
-87
View File
@@ -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
}
+2 -2
View File
@@ -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
View File
@@ -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",
+6 -8
View File
@@ -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"));
const url = URL.createObjectURL(new ReadableStream({
async start(controller) {
controller.enqueue(new TextEncoder().encode("stale\n"));
await new Promise((resolve) => setTimeout(resolve, 50));
await writer.abort("expired");
})();
controller.error(new Error("expired"));
},
}));
ctx.res = {
name: "gblob-recover",
+58
View File
@@ -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"
}
]
}
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
+3
View File
@@ -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
WebViewProvider enginewebview.Provider `json:"-"`
WebEnable bool
WebFS fs.FS
+1
View File
@@ -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)
+100
View File
@@ -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
+1 -1
View File
@@ -24,7 +24,7 @@ if (flutterVersionName == null) {
android {
namespace 'com.gopeed.gopeed'
compileSdk 35
compileSdk 36
ndkVersion flutter.ndkVersion
compileOptions {
+1 -1
View File
@@ -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;
}
+2
View File
@@ -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,
+2
View File
@@ -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,
};
+1 -1
View File
@@ -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 {
+1
View File
@@ -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';
@@ -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 {
return;
}
try {
await createTask(createTaskParams);
} catch (e) {
logger.w(
"create task from extension fail", e, StackTrace.current);
}
logger.w("create task from extension fail", e, StackTrace.current);
}
},
"/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(
onForward: ({
required String path,
required String method,
data,
Map<String, dynamic>? query,
}) {
return forward(
path,
method: method,
data: data,
queryParameters: query,
);
// Return raw response
await ctx.writeJSON(response.data);
} 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());
}
}
},
});
);
} 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);
}
}
+198
View File
@@ -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();
+19 -1
View File
@@ -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,
};
+5
View File
@@ -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) {
+16
View File
@@ -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
+64
View File
@@ -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:
+1
View File
@@ -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: