From def54f8998208534ce7bcea65737084263bfe6dc Mon Sep 17 00:00:00 2001 From: Levi Date: Fri, 24 Apr 2026 22:16:33 +0800 Subject: [PATCH] feat: add WebView support for extension (#1359) --- .github/workflows/build.yml | 22 +- .github/workflows/release.yml | 4 +- .github/workflows/test.yml | 84 +- Dockerfile | 2 +- README.md | 2 +- README_ja-JP.md | 2 +- README_vi-VN.md | 2 +- README_zh-CN.md | 2 +- README_zh-TW.md | 2 +- bind/desktop/main.go | 1 + bind/desktop/webview_provider_darwin.go | 23 + bind/desktop/webview_provider_other.go | 15 + bind/mobile/main.go | 9 + cmd/extmock/main.go | 452 ++- go.mod | 7 +- go.sum | 8 +- internal/protocol/gblob/README.md | 98 +- internal/protocol/gblob/fetcher.go | 144 +- internal/protocol/gblob/fetcher_test.go | 122 + internal/protocol/gblob/registry.go | 216 +- internal/protocol/gblob/registry_test.go | 46 + internal/protocol/http/fetcher_test.go | 6 +- internal/test/httptest.go | 31 +- .../webview/goprovider/mainthread_darwin.go | 36 + .../webview/goprovider/mainthread_other.go | 11 + internal/webview/goprovider/provider.go | 608 ++++ .../goprovider/provider_integration_test.go | 15 + internal/webview/goprovider/provider_stub.go | 9 + internal/webview/goprovider/window_darwin.go | 38 + internal/webview/goprovider/window_other.go | 10 + internal/webview/integrationtest/contract.go | 378 +++ internal/webview/rpcprovider/README.md | 488 +++ internal/webview/rpcprovider/provider.go | 195 ++ .../rpcprovider/provider_integration_test.go | 41 + .../rpcprovider/provider_socket_test.go | 50 + internal/webview/rpcprovider/provider_test.go | 186 + pkg/download/downloader.go | 55 +- pkg/download/downloader_test.go | 86 +- pkg/download/engine/engine.go | 150 +- pkg/download/engine/engine_test.go | 165 +- pkg/download/engine/inject/stream/module.go | 438 ++- pkg/download/engine/inject/stream/stream.js | 579 +++- pkg/download/engine/inject/vm/module.go | 9 +- pkg/download/engine/polyfill/out/index.js | 2 +- .../engine/polyfill/package-lock.json | 3021 +++++------------ pkg/download/engine/polyfill/package.json | 2 + .../engine/polyfill/src/abort/index.js | 2 + .../engine/polyfill/src/event/index.js | 9 + pkg/download/engine/polyfill/src/index.js | 4 +- pkg/download/engine/webview/provider.go | 20 + pkg/download/engine/webview/rpc.go | 143 + pkg/download/engine/webview/runtime.go | 765 +++++ pkg/download/engine/webview/runtime_test.go | 338 ++ pkg/download/extension.go | 48 +- pkg/download/extension_engine.go | 75 + pkg/download/extension_engine_session.go | 60 +- pkg/download/extension_runtime_webview.go | 258 ++ pkg/download/extension_test.go | 1490 +++++++- pkg/download/extmock.go | 87 - pkg/download/model.go | 4 +- .../testdata/extensions/gblob/index.js | 145 +- .../extensions/gblob_recover/index.js | 16 +- .../extensions/gblob_restart/index.js | 58 + .../extensions/gblob_restart/manifest.json | 28 + pkg/download/testdata/scripts/env_dump.sh | 0 pkg/download/testdata/scripts/move.sh | 0 .../testdata/scripts/write_output1.sh | 0 .../testdata/scripts/write_output2.sh | 0 pkg/rest/model/server.go | 5 +- pkg/rest/server.go | 1 + pkg/rest/server_test.go | 100 + ui/flutter/android/app/build.gradle | 2 +- ui/flutter/lib/api/api.dart | 2 +- .../lib/api/model/create_task_batch.g.dart | 4 +- ui/flutter/lib/api/model/request.dart | 2 + ui/flutter/lib/api/model/request.g.dart | 2 + ui/flutter/lib/api/model/task.dart | 2 +- ui/flutter/lib/api/model/task.g.dart | 1 + .../app/controllers/app_controller.dart | 69 +- .../lib/app/modules/app/views/app_view.dart | 12 +- .../lib/app/modules/task/views/task_view.dart | 13 +- ui/flutter/lib/app/rpc/host_rpc_service.dart | 90 + ui/flutter/lib/app/rpc/server.dart | 198 ++ .../lib/app/rpc/webview_rpc_overlay.dart | 97 + .../lib/app/rpc/webview_rpc_service.dart | 776 +++++ .../lib/app/views/buid_task_list_view.dart | 5 +- ui/flutter/lib/core/common/start_config.dart | 15 + .../lib/core/common/start_config.g.dart | 20 +- ui/flutter/lib/main.dart | 5 + ui/flutter/macos/Podfile.lock | 16 + ui/flutter/pubspec.lock | 64 + ui/flutter/pubspec.yaml | 1 + 92 files changed, 10248 insertions(+), 2676 deletions(-) create mode 100644 bind/desktop/webview_provider_darwin.go create mode 100644 bind/desktop/webview_provider_other.go create mode 100644 internal/protocol/gblob/fetcher_test.go create mode 100644 internal/protocol/gblob/registry_test.go create mode 100644 internal/webview/goprovider/mainthread_darwin.go create mode 100644 internal/webview/goprovider/mainthread_other.go create mode 100644 internal/webview/goprovider/provider.go create mode 100644 internal/webview/goprovider/provider_integration_test.go create mode 100644 internal/webview/goprovider/provider_stub.go create mode 100644 internal/webview/goprovider/window_darwin.go create mode 100644 internal/webview/goprovider/window_other.go create mode 100644 internal/webview/integrationtest/contract.go create mode 100644 internal/webview/rpcprovider/README.md create mode 100644 internal/webview/rpcprovider/provider.go create mode 100644 internal/webview/rpcprovider/provider_integration_test.go create mode 100644 internal/webview/rpcprovider/provider_socket_test.go create mode 100644 internal/webview/rpcprovider/provider_test.go create mode 100644 pkg/download/engine/polyfill/src/abort/index.js create mode 100644 pkg/download/engine/polyfill/src/event/index.js create mode 100644 pkg/download/engine/webview/provider.go create mode 100644 pkg/download/engine/webview/rpc.go create mode 100644 pkg/download/engine/webview/runtime.go create mode 100644 pkg/download/engine/webview/runtime_test.go create mode 100644 pkg/download/extension_engine.go create mode 100644 pkg/download/extension_runtime_webview.go delete mode 100644 pkg/download/extmock.go create mode 100644 pkg/download/testdata/extensions/gblob_restart/index.js create mode 100644 pkg/download/testdata/extensions/gblob_restart/manifest.json mode change 100644 => 100755 pkg/download/testdata/scripts/env_dump.sh mode change 100644 => 100755 pkg/download/testdata/scripts/move.sh mode change 100644 => 100755 pkg/download/testdata/scripts/write_output1.sh mode change 100644 => 100755 pkg/download/testdata/scripts/write_output2.sh create mode 100644 ui/flutter/lib/app/rpc/host_rpc_service.dart create mode 100644 ui/flutter/lib/app/rpc/server.dart create mode 100644 ui/flutter/lib/app/rpc/webview_rpc_overlay.dart create mode 100644 ui/flutter/lib/app/rpc/webview_rpc_service.dart diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 449fc4e7..85e42431 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc876040..d8f673a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 835a70d9..0b4fedbe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ on: workflow_dispatch: env: - GO_VERSION: "1.24" + GO_VERSION: "1.25" FLUTTER_VERSION: "3.41.6" jobs: @@ -50,14 +50,18 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} + - name: Install webview dependencies + run: | + sudo apt-get update -y + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev xvfb - name: Run Unit tests. run: | # Get packages for testing and coverage (including cmd/web for flags_test.go) - PACKAGES=$(go list ./... | grep -v /bind/ | grep -v -E '/cmd/(?!web)') - go test -v -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $PACKAGES + PACKAGES=$(go list ./... | awk '!/\/bind\// && (!/\/cmd\// || /\/cmd\/web$/)') + env CGO_ENABLED=1 xvfb-run -a go test -tags webview -v -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $PACKAGES # Filter out main.go from coverage report to avoid lowering coverage percentage grep -v "main.go" coverage.txt > coverage_filtered.txt || true mv coverage_filtered.txt coverage.txt @@ -65,6 +69,58 @@ jobs: with: files: ./coverage.txt token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} + test-webview-integration: + name: Test webview integration (${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - name: Windows + os: windows-latest + - name: macOS + os: macos-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + - uses: subosito/flutter-action@v2 + if: runner.os == 'macOS' + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + - name: Test webview + if: runner.os == 'Windows' + run: go test -tags webview -v -timeout 300s ./internal/webview/goprovider/ + env: + CGO_ENABLED: 1 + - name: Test webview + if: runner.os == 'macOS' + shell: bash + run: | + set -euo pipefail + SOCKET_PATH="$HOME/Library/Application Support/com.gopeed.gopeed/gopeed_webview.sock" + LOG_PATH="$RUNNER_TEMP/gopeed-webview-rpc.log" + rm -f "$SOCKET_PATH" + + go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop + + cd ui/flutter + flutter pub get + flutter run -d macos >"$LOG_PATH" 2>&1 & + FLUTTER_PID=$! + cd ../.. + + cleanup() { + kill "$FLUTTER_PID" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + if ! go test -tags webview -v -timeout 600s ./internal/webview/rpcprovider/ -args -webview-rpc-network unix -webview-rpc-address "$SOCKET_PATH"; then + cat "$LOG_PATH" + exit 1 + fi + build-desktop: strategy: matrix: @@ -80,7 +136,7 @@ jobs: flutter_channel: "main" name: Build desktop check (${{ matrix.os }}) runs-on: ${{ matrix.os }} - needs: [test] + needs: [ test ] steps: - uses: actions/checkout@v3 - name: Enable long paths for flutter main branch checks @@ -109,7 +165,7 @@ jobs: "CGO_ENABLED=1" >> $env:GITHUB_ENV "CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV $b >> $env:GITHUB_PATH - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} - uses: subosito/flutter-action@v2 @@ -129,7 +185,7 @@ jobs: - if: runner.os == 'Linux' run: | sudo apt-get update -y - sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libkeybinder-3.0-dev + sudo apt-get install -y ninja-build libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev libkeybinder-3.0-dev go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop cd ui/flutter flutter build linux @@ -138,11 +194,11 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [macos-14, ubuntu-latest] - needs: [test] + os: [ macos-14, ubuntu-latest ] + needs: [ test ] steps: - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} - uses: subosito/flutter-action@v2 @@ -171,10 +227,10 @@ jobs: build-web: name: Build web check runs-on: ubuntu-latest - needs: [test] + needs: [ test ] steps: - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} - uses: subosito/flutter-action@v2 @@ -192,10 +248,10 @@ jobs: build-docker: name: Build docker check runs-on: ubuntu-latest - needs: [test] + needs: [ test ] steps: - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} - uses: subosito/flutter-action@v2 diff --git a/Dockerfile b/Dockerfile index 6682788d..0fbdc9be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 5f7ad7f3..e0b7fdfd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_ja-JP.md b/README_ja-JP.md index b59a29ee..ae3caad1 100644 --- a/README_ja-JP.md +++ b/README_ja-JP.md @@ -182,7 +182,7 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された ### 🌍 環境 -1. Go 言語 1.24+ +1. Go 言語 1.25+ 2. Flutter 3.38+ ### 📋 クローン diff --git a/README_vi-VN.md b/README_vi-VN.md index ecd010bf..80096816 100644 --- a/README_vi-VN.md +++ b/README_vi-VN.md @@ -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 diff --git a/README_zh-CN.md b/README_zh-CN.md index 22f8f623..92ed80ef 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -192,7 +192,7 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、 ### 🌍 环境要求 -1. Golang 1.24+ +1. Golang 1.25+ 2. Flutter 3.38+ ### 📋 克隆项目 diff --git a/README_zh-TW.md b/README_zh-TW.md index 796e5e79..2682cb89 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -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+ ### 📋 克隆項目 diff --git a/bind/desktop/main.go b/bind/desktop/main.go index 5b63770f..b809271d 100644 --- a/bind/desktop/main.go +++ b/bind/desktop/main.go @@ -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()) diff --git a/bind/desktop/webview_provider_darwin.go b/bind/desktop/webview_provider_darwin.go new file mode 100644 index 00000000..3b56958c --- /dev/null +++ b/bind/desktop/webview_provider_darwin.go @@ -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() +} diff --git a/bind/desktop/webview_provider_other.go b/bind/desktop/webview_provider_other.go new file mode 100644 index 00000000..6de39d2d --- /dev/null +++ b/bind/desktop/webview_provider_other.go @@ -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() +} diff --git a/bind/mobile/main.go b/bind/mobile/main.go index 0a81ba53..5bdcf649 100644 --- a/bind/mobile/main.go +++ b/bind/mobile/main.go @@ -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) +} diff --git a/cmd/extmock/main.go b/cmd/extmock/main.go index 8a79fed7..92903e04 100644 --- a/cmd/extmock/main.go +++ b/cmd/extmock/main.go @@ -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] + extmock download [flags] 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: ") + } + + 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 { diff --git a/go.mod b/go.mod index 7b27ae99..785e2142 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index c6a40635..7b328f6d 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/protocol/gblob/README.md b/internal/protocol/gblob/README.md index 63276974..ed37a2e9 100644 --- a/internal/protocol/gblob/README.md +++ b/internal/protocol/gblob/README.md @@ -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: ## 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:` 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 diff --git a/internal/protocol/gblob/fetcher.go b/internal/protocol/gblob/fetcher.go index 0545eece..5dfa915c 100644 --- a/internal/protocol/gblob/fetcher.go +++ b/internal/protocol/gblob/fetcher.go @@ -62,15 +62,21 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error { if name == "" { name = src.ID } - size := src.Snapshot().Written + snapshot := src.Snapshot() + size := snapshot.Written + if snapshot.DeclaredSize > 0 { + size = snapshot.DeclaredSize + } if src.Type == SourceTypeWritableStream { // Writable streams may continue producing bytes after Resolve, // so treat the total size as unknown until the writer closes. - size = 0 + if snapshot.DeclaredSize == 0 { + size = 0 + } } f.meta.Res = &base.Resource{ Size: size, - Range: false, + Range: snapshot.Range, Files: []*base.FileInfo{ { Name: name, @@ -102,45 +108,90 @@ func (f *Fetcher) Start() error { f.cancel = cancel f.runDone = runDone f.mu.Unlock() + cleanupStartErr := func(err error) error { + f.mu.Lock() + if f.runDone == runDone { + f.runDone = nil + f.cancel = nil + } + f.mu.Unlock() + return err + } path := f.meta.SingleFilepath() if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err + return cleanupStartErr(err) } target, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644) if err != nil { - return err + return cleanupStartErr(err) } offset := int64(0) - if f.lastSourceID != "" && f.lastSourceID != currentSourceID { + allowResumeAcrossSource := f.meta.Res != nil && f.meta.Res.Range + if f.lastSourceID != "" && f.lastSourceID != currentSourceID && !allowResumeAcrossSource { if err := target.Truncate(0); err != nil { target.Close() - return err + return cleanupStartErr(err) } if _, err := target.Seek(0, io.SeekStart); err != nil { target.Close() - return err + return cleanupStartErr(err) } } else { stat, err := target.Stat() if err != nil { target.Close() - return err + return cleanupStartErr(err) } offset = stat.Size() if _, err := target.Seek(offset, io.SeekStart); err != nil { target.Close() - return err + return cleanupStartErr(err) } } f.lastSourceID = currentSourceID f.downloaded.Store(offset) - go f.copyLoop(ctx, target, offset) + sourceOffset := offset + src, err := f.registry.Get(f.meta.Req.URL) + if err != nil { + target.Close() + return cleanupStartErr(err) + } + snapshot := src.Snapshot() + if snapshot.Written == 0 && !snapshot.Started { + if f.meta.Res != nil && f.meta.Res.Range && offset > 0 { + if err := f.registry.Reopen(f.meta.Req.URL, offset); err != nil { + target.Close() + return cleanupStartErr(err) + } + sourceOffset = 0 + } else if err := f.registry.StartSource(f.meta.Req.URL); err != nil { + target.Close() + return cleanupStartErr(err) + } + src, err = f.registry.Get(f.meta.Req.URL) + if err != nil { + target.Close() + return cleanupStartErr(err) + } + snapshot = src.Snapshot() + } + if f.meta.Res != nil && f.meta.Res.Range && (snapshot.State == SourceStateAborted || snapshot.State == SourceStateFailed) { + if err := f.registry.Reopen(f.meta.Req.URL, offset); err != nil { + target.Close() + return cleanupStartErr(err) + } + sourceOffset = 0 + } + + go f.copyLoop(ctx, target, offset, sourceOffset) return nil } -func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, offset int64) { +func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, targetOffset, sourceOffset int64) { + var finalErr error + defer target.Close() defer func() { f.mu.Lock() @@ -161,85 +212,75 @@ func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, offset int64) { if paused { return } + select { + case f.doneCh <- finalErr: + default: + } }() src, err := f.registry.Get(f.meta.Req.URL) if err != nil { - select { - case f.doneCh <- err: - default: - } + finalErr = err return } sourceFile, err := os.Open(src.Path) if err != nil { - select { - case f.doneCh <- err: - default: - } + finalErr = err return } defer sourceFile.Close() buf := make([]byte, 32*1024) for { - if err := f.registry.WaitForReadable(ctx, f.meta.Req.URL, offset); err != nil { + if err := f.registry.WaitForReadable(ctx, f.meta.Req.URL, sourceOffset); err != nil { if errors.Is(err, context.Canceled) { return } - select { - case f.doneCh <- err: - default: - } + finalErr = err return } snapshot, serr := f.registry.Get(f.meta.Req.URL) if serr != nil { - select { - case f.doneCh <- serr: - default: - } + finalErr = serr return } current := snapshot.Snapshot() - for offset < current.Written { - remain := current.Written - offset + for sourceOffset < current.Written { + remain := current.Written - sourceOffset readSize := int64(len(buf)) if remain < readSize { readSize = remain } - n, err := sourceFile.ReadAt(buf[:readSize], offset) + n, err := sourceFile.ReadAt(buf[:readSize], sourceOffset) if err != nil && !errors.Is(err, io.EOF) { - select { - case f.doneCh <- err: - default: - } + finalErr = err return } if n == 0 { break } if _, err := target.Write(buf[:n]); err != nil { - select { - case f.doneCh <- err: - default: - } + finalErr = err return } - offset += int64(n) - f.downloaded.Store(offset) + sourceOffset += int64(n) + targetOffset += int64(n) + f.downloaded.Store(targetOffset) } - if current.State == SourceStateClosed && offset >= current.Written { + if current.State == SourceStateClosed && sourceOffset >= current.Written { if f.meta.Res != nil { - f.meta.Res.Size = current.Written - f.meta.Res.Files[0].Size = current.Written - } - select { - case f.doneCh <- nil: - default: + if f.meta.Res.Size > 0 && targetOffset < f.meta.Res.Size { + finalErr = io.ErrUnexpectedEOF + return + } + if f.meta.Res.Size == 0 { + f.meta.Res.Size = targetOffset + } + f.meta.Res.Files[0].Size = f.meta.Res.Size } + finalErr = nil return } if current.State == SourceStateAborted || current.State == SourceStateFailed { @@ -247,10 +288,7 @@ func (f *Fetcher) copyLoop(ctx context.Context, target *os.File, offset int64) { if err == nil { err = ErrSourceAborted } - select { - case f.doneCh <- err: - default: - } + finalErr = err return } } diff --git a/internal/protocol/gblob/fetcher_test.go b/internal/protocol/gblob/fetcher_test.go new file mode 100644 index 00000000..27414d2f --- /dev/null +++ b/internal/protocol/gblob/fetcher_test.go @@ -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)) + } +} diff --git a/internal/protocol/gblob/registry.go b/internal/protocol/gblob/registry.go index 7a1f0a8e..b1919b7f 100644 --- a/internal/protocol/gblob/registry.go +++ b/internal/protocol/gblob/registry.go @@ -42,11 +42,21 @@ type SessionRef interface { Release() } +type ResumeFunc func(offset int64) error + +type CreateWritableStreamOptions struct { + Session SessionRef + Reopenable bool +} + type Snapshot struct { - Written int64 - State SourceState - Err error - WaitCh <-chan struct{} + Written int64 + DeclaredSize int64 + Range bool + Started bool + State SourceState + Err error + WaitCh <-chan struct{} } type Source struct { @@ -66,16 +76,24 @@ type Source struct { waitCh chan struct{} session SessionRef sessionReleased bool + reopenable bool + rangeEnabled bool + declaredSize int64 + started bool + reopen ResumeFunc } func (s *Source) Snapshot() Snapshot { s.mu.Lock() defer s.mu.Unlock() return Snapshot{ - Written: s.written, - State: s.state, - Err: s.err, - WaitCh: s.waitCh, + Written: s.written, + DeclaredSize: s.declaredSize, + Range: s.rangeEnabled, + Started: s.started, + State: s.state, + Err: s.err, + WaitCh: s.waitCh, } } @@ -122,6 +140,13 @@ func (s *Source) releaseSessionLocked() { } } +func (s *Source) retainSessionLocked() { + if s.session != nil && s.sessionReleased { + s.session.Retain() + s.sessionReleased = false + } +} + func (s *Source) write(data []byte) error { s.mu.Lock() defer s.mu.Unlock() @@ -146,7 +171,9 @@ func (s *Source) write(data []byte) error { s.err = err s.file.Close() s.file = nil - s.releaseSessionLocked() + if !s.rangeEnabled { + s.releaseSessionLocked() + } s.notifyLocked() return err } @@ -187,7 +214,9 @@ func (s *Source) abort(err error) error { _ = s.file.Close() s.file = nil } - s.releaseSessionLocked() + if !s.rangeEnabled { + s.releaseSessionLocked() + } s.notifyLocked() return nil } @@ -196,6 +225,18 @@ func (s *Source) revoke() bool { s.mu.Lock() defer s.mu.Unlock() s.revoked = true + if s.state == SourceStateOpen { + s.state = SourceStateAborted + if s.err == nil { + s.err = ErrSourceRevoked + } + if s.file != nil { + _ = s.file.Close() + s.file = nil + } + s.releaseSessionLocked() + s.notifyLocked() + } return s.shouldCleanupLocked() } @@ -229,6 +270,13 @@ type Registry struct { sources map[string]*Source } +func (r *Registry) Dir() string { + if r == nil { + return "" + } + return r.dir +} + func NewRegistry(baseDir string) *Registry { dir := baseDir if dir == "" { @@ -258,7 +306,7 @@ func ParseURL(raw string) (string, error) { return id, nil } -func (r *Registry) createSource(sourceType SourceType, contentType string, session SessionRef) (*Source, error) { +func (r *Registry) createSource(sourceType SourceType, contentType string, session SessionRef, reopenable bool, declaredSize int64) (*Source, error) { if err := os.MkdirAll(r.dir, 0755); err != nil { return nil, err } @@ -275,15 +323,19 @@ func (r *Registry) createSource(sourceType SourceType, contentType string, sessi session.Retain() } src := &Source{ - ID: id, - URL: BuildURL(id), - Path: filePath, - Type: sourceType, - ContentType: contentType, - file: file, - state: SourceStateOpen, - waitCh: make(chan struct{}), - session: session, + ID: id, + URL: BuildURL(id), + Path: filePath, + Type: sourceType, + ContentType: contentType, + file: file, + state: SourceStateOpen, + waitCh: make(chan struct{}), + session: session, + reopenable: reopenable, + rangeEnabled: reopenable, + declaredSize: declaredSize, + started: !reopenable, } r.mu.Lock() r.sources[id] = src @@ -292,7 +344,7 @@ func (r *Registry) createSource(sourceType SourceType, contentType string, sessi } func (r *Registry) CreateBlob(data []byte, contentType string) (string, error) { - src, err := r.createSource(SourceTypeBlob, contentType, nil) + src, err := r.createSource(SourceTypeBlob, contentType, nil, false, int64(len(data))) if err != nil { return "", err } @@ -307,14 +359,59 @@ func (r *Registry) CreateBlob(data []byte, contentType string) (string, error) { return src.URL, nil } -func (r *Registry) CreateWritableStream(session SessionRef) (string, error) { - src, err := r.createSource(SourceTypeWritableStream, "", session) +func (r *Registry) CreateWritableStream(opts *CreateWritableStreamOptions) (string, error) { + if opts == nil { + opts = &CreateWritableStreamOptions{} + } + src, err := r.createSource(SourceTypeWritableStream, "", opts.Session, opts.Reopenable, 0) if err != nil { return "", err } return src.URL, nil } +func (r *Registry) SetResumeOpener(raw string, reopen ResumeFunc) error { + src, err := r.get(raw) + if err != nil { + return err + } + src.mu.Lock() + defer src.mu.Unlock() + if !src.reopenable { + return nil + } + src.reopen = reopen + return nil +} + +func (r *Registry) SetRange(raw string, enabled bool) error { + src, err := r.get(raw) + if err != nil { + return err + } + src.mu.Lock() + src.rangeEnabled = enabled + if !enabled && (src.state == SourceStateAborted || src.state == SourceStateFailed) { + src.releaseSessionLocked() + } + src.mu.Unlock() + return nil +} + +func (r *Registry) SetSize(raw string, size int64) error { + src, err := r.get(raw) + if err != nil { + return err + } + src.mu.Lock() + if size < 0 { + size = 0 + } + src.declaredSize = size + src.mu.Unlock() + return nil +} + func (r *Registry) get(raw string) (*Source, error) { id, err := ParseURL(raw) if err != nil { @@ -405,10 +502,80 @@ func (r *Registry) WaitForReadable(ctx context.Context, raw string, offset int64 return src.waitForReadable(ctx, offset) } +func (r *Registry) Reopen(raw string, offset int64) error { + src, err := r.get(raw) + if err != nil { + return err + } + + src.mu.Lock() + if !src.reopenable || src.reopen == nil { + src.mu.Unlock() + return ErrSourceNotFound + } + if src.revoked { + src.mu.Unlock() + return ErrSourceRevoked + } + if src.file != nil { + _ = src.file.Close() + src.file = nil + } + if err := os.MkdirAll(filepath.Dir(src.Path), 0755); err != nil { + src.mu.Unlock() + return err + } + file, err := os.Create(src.Path) + if err != nil { + src.mu.Unlock() + return err + } + src.file = file + src.written = 0 + src.state = SourceStateOpen + src.err = nil + src.started = true + src.retainSessionLocked() + reopen := src.reopen + src.mu.Unlock() + + if err := reopen(offset); err != nil { + src.mu.Lock() + if src.file != nil { + _ = src.file.Close() + src.file = nil + } + src.state = SourceStateFailed + src.err = err + src.releaseSessionLocked() + src.notifyLocked() + src.mu.Unlock() + return err + } + return nil +} + +func (r *Registry) StartSource(raw string) error { + src, err := r.get(raw) + if err != nil { + return err + } + src.mu.Lock() + if !src.reopenable || src.started { + src.mu.Unlock() + return nil + } + src.mu.Unlock() + return r.Reopen(raw, 0) +} + func (r *Registry) cleanupIfNeeded(src *Source) { src.mu.Lock() shouldCleanup := src.shouldCleanupLocked() file := src.file + if shouldCleanup { + src.releaseSessionLocked() + } src.mu.Unlock() if !shouldCleanup { return @@ -443,5 +610,8 @@ func (r *Registry) Close() error { lastErr = err } } + if err := os.RemoveAll(r.dir); err != nil && !errors.Is(err, os.ErrNotExist) { + lastErr = err + } return lastErr } diff --git a/internal/protocol/gblob/registry_test.go b/internal/protocol/gblob/registry_test.go new file mode 100644 index 00000000..be601f82 --- /dev/null +++ b/internal/protocol/gblob/registry_test.go @@ -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) + } +} diff --git a/internal/protocol/http/fetcher_test.go b/internal/protocol/http/fetcher_test.go index 1dfd00dc..ac086180 100644 --- a/internal/protocol/http/fetcher_test.go +++ b/internal/protocol/http/fetcher_test.go @@ -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() diff --git a/internal/test/httptest.go b/internal/test/httptest.go index 2b2372dd..2b476499 100644 --- a/internal/test/httptest.go +++ b/internal/test/httptest.go @@ -433,9 +433,8 @@ func StartTestExpiringRedirectServer(requestsBeforeExpire int32, delayPerByte ti }) } -// StartTestSlowStartServer creates a server with configurable delay per request -// This allows testing slow-start connection expansion to reach max connections -func StartTestSlowStartServer(delayPerByte time.Duration) net.Listener { +// StartTestLowSpeedServer creates a server with configurable delay per request +func StartTestLowSpeedServer(delayPerByte time.Duration) net.Listener { return startTestServer(func(sl *shutdownListener) http.Handler { mux := http.NewServeMux() mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) { @@ -484,10 +483,9 @@ func slowCopyNWithDelay(sl *shutdownListener, dst io.Writer, src io.Reader, n in } } -// StartTestLimitServer connections limit server -func StartTestLimitServer(maxConnections int32, delay int64) net.Listener { +// StartTestConLimitServer connections limit server +func StartTestConLimitServer(maxConnections int32) net.Listener { var connections atomic.Int32 - var slowOnce atomic.Bool return startTestServer(func(sl *shutdownListener) http.Handler { mux := http.NewServeMux() @@ -500,19 +498,11 @@ func StartTestLimitServer(maxConnections int32, delay int64) net.Listener { writer.WriteHeader(403) return } - - // First request intentionally delays the first write to trigger a single read timeout, - // subsequent requests respond at normal speed. - useInitialDelay := delay > 0 && !slowOnce.Swap(true) rangeFileHandle( writer, request, nil, func(file *os.File, n int64) { - if useInitialDelay { - slowCopyAfterDelay(sl, writer, file, n, delay) - return - } slowCopyN(sl, writer, file, n, 0) }, ) @@ -711,10 +701,13 @@ func rangeFileHandle(writer http.ResponseWriter, request *http.Request, modifyEn writer.WriteHeader(400) return } - end, err := strconv.ParseInt(s[1], 10, 64) - if err != nil { - writer.WriteHeader(400) - return + end := int64(BuildSize - 1) + if s[1] != "" { + end, err = strconv.ParseInt(s[1], 10, 64) + if err != nil { + writer.WriteHeader(400) + return + } } if start < 0 || end < 0 || start > end { writer.WriteHeader(400) @@ -880,7 +873,7 @@ func StartSocks5Server(usr, pwd string) net.Listener { panic(err) } - listener, err := net.Listen("tcp", "127.0.0.1:0") // 你可以根据需要更改监听地址 + listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { panic(err) } diff --git a/internal/webview/goprovider/mainthread_darwin.go b/internal/webview/goprovider/mainthread_darwin.go new file mode 100644 index 00000000..06ad6ab6 --- /dev/null +++ b/internal/webview/goprovider/mainthread_darwin.go @@ -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 +} diff --git a/internal/webview/goprovider/mainthread_other.go b/internal/webview/goprovider/mainthread_other.go new file mode 100644 index 00000000..aa3ee4a1 --- /dev/null +++ b/internal/webview/goprovider/mainthread_other.go @@ -0,0 +1,11 @@ +//go:build !darwin + +package goprovider + +func RunMainThreadLoop(run func() int) int { + return run() +} + +func postMainThreadTask(func()) bool { + return false +} diff --git a/internal/webview/goprovider/provider.go b/internal/webview/goprovider/provider.go new file mode 100644 index 00000000..660f6394 --- /dev/null +++ b/internal/webview/goprovider/provider.go @@ -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(` + + + + + +`, 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 "" +} diff --git a/internal/webview/goprovider/provider_integration_test.go b/internal/webview/goprovider/provider_integration_test.go new file mode 100644 index 00000000..92f226e7 --- /dev/null +++ b/internal/webview/goprovider/provider_integration_test.go @@ -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, + }) +} diff --git a/internal/webview/goprovider/provider_stub.go b/internal/webview/goprovider/provider_stub.go new file mode 100644 index 00000000..21026aa2 --- /dev/null +++ b/internal/webview/goprovider/provider_stub.go @@ -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() +} diff --git a/internal/webview/goprovider/window_darwin.go b/internal/webview/goprovider/window_darwin.go new file mode 100644 index 00000000..69dfedda --- /dev/null +++ b/internal/webview/goprovider/window_darwin.go @@ -0,0 +1,38 @@ +//go:build cgo && darwin + +package goprovider + +/* +#cgo darwin CFLAGS: -x objective-c +#cgo darwin LDFLAGS: -framework Cocoa + +#include + +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 +} diff --git a/internal/webview/goprovider/window_other.go b/internal/webview/goprovider/window_other.go new file mode 100644 index 00000000..c0c81747 --- /dev/null +++ b/internal/webview/goprovider/window_other.go @@ -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) {} diff --git a/internal/webview/integrationtest/contract.go b/internal/webview/integrationtest/contract.go new file mode 100644 index 00000000..15dd5ca7 --- /dev/null +++ b/internal/webview/integrationtest/contract.go @@ -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, ` + + Next Page + +

Navigation complete

+ +`) + default: + fmt.Fprint(w, ` + + Test Page + + +
+ +
0
+ + + +`) + } + })) + 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 +} diff --git a/internal/webview/rpcprovider/README.md b/internal/webview/rpcprovider/README.md new file mode 100644 index 00000000..3590f557 --- /dev/null +++ b/internal/webview/rpcprovider/README.md @@ -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 `. + +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": "", + "params": { ... } +} +``` + +and returns: + +```json +{ + "result": ..., + "error": null +} +``` + +or: + +```json +{ + "result": null, + "error": { + "code": "", + "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. diff --git a/internal/webview/rpcprovider/provider.go b/internal/webview/rpcprovider/provider.go new file mode 100644 index 00000000..9a22bea2 --- /dev/null +++ b/internal/webview/rpcprovider/provider.go @@ -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) +} diff --git a/internal/webview/rpcprovider/provider_integration_test.go b/internal/webview/rpcprovider/provider_integration_test.go new file mode 100644 index 00000000..f4671986 --- /dev/null +++ b/internal/webview/rpcprovider/provider_integration_test.go @@ -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") +} diff --git a/internal/webview/rpcprovider/provider_socket_test.go b/internal/webview/rpcprovider/provider_socket_test.go new file mode 100644 index 00000000..a45d376f --- /dev/null +++ b/internal/webview/rpcprovider/provider_socket_test.go @@ -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") + } +} diff --git a/internal/webview/rpcprovider/provider_test.go b/internal/webview/rpcprovider/provider_test.go new file mode 100644 index 00000000..9ec03ac1 --- /dev/null +++ b/internal/webview/rpcprovider/provider_test.go @@ -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) + } +} diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index d1e0ce36..ace48ca8 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -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 { diff --git a/pkg/download/downloader_test.go b/pkg/download/downloader_test.go index e131834d..00f2cc25 100644 --- a/pkg/download/downloader_test.go +++ b/pkg/download/downloader_test.go @@ -21,12 +21,14 @@ import ( "github.com/GopeedLab/gopeed/pkg/util" ) -var testDownloadOpt = &base.Options{ - Path: test.Dir, - Name: test.DownloadName, - Extra: http.OptsExtra{ - Connections: 4, - }, +func newTestDownloadOpt() *base.Options { + return &base.Options{ + Path: test.Dir, + Name: test.DownloadName, + Extra: http.OptsExtra{ + Connections: 4, + }, + } } func TestDownloader_Resolve(t *testing.T) { @@ -61,6 +63,51 @@ func TestDownloader_Resolve(t *testing.T) { } } +func TestDownloader_UsesStorageDirForGBlobRegistry(t *testing.T) { + storageDir := t.TempDir() + downloader := NewDownloader(&DownloaderConfig{ + Storage: NewMemStorage(), + StorageDir: storageDir, + }) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + if downloader.gblob == nil { + t.Fatal("expected gblob registry to be initialized") + } + expectedDir := filepath.Join(storageDir, "gblob") + if filepath.Clean(downloader.gblob.Dir()) != filepath.Clean(expectedDir) { + t.Fatalf("expected gblob registry dir under storage dir, got %s want %s", downloader.gblob.Dir(), expectedDir) + } +} + +func TestDownloader_SetupCleansStaleGBlobDir(t *testing.T) { + storageDir := t.TempDir() + gblobDir := filepath.Join(storageDir, "gblob") + if err := os.MkdirAll(gblobDir, 0o755); err != nil { + t.Fatal(err) + } + staleFile := filepath.Join(gblobDir, "stale") + if err := os.WriteFile(staleFile, []byte("stale"), 0o644); err != nil { + t.Fatal(err) + } + + downloader := NewDownloader(&DownloaderConfig{ + Storage: NewMemStorage(), + StorageDir: storageDir, + }) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + if _, err := os.Stat(staleFile); !os.IsNotExist(err) { + t.Fatalf("expected stale gblob file to be removed on setup, got err=%v", err) + } +} + func TestDownloader_Create(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() @@ -73,7 +120,7 @@ func TestDownloader_Create(t *testing.T) { req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } - rr, err := downloader.Resolve(req, testDownloadOpt) + rr, err := downloader.Resolve(req, newTestDownloadOpt()) if err != nil { t.Fatal(err) } @@ -113,7 +160,7 @@ func TestDownloader_CreateNotInWhite(t *testing.T) { } // With new fetcher design, white list check happens during Resolve (not Create) // because Resolve now requires Options which includes the download path - _, err := downloader.Resolve(req, testDownloadOpt) + _, err := downloader.Resolve(req, newTestDownloadOpt()) if err == nil { t.Error("TestDownloader_CreateNotInWhite() expected error but got nil") } @@ -162,7 +209,7 @@ func TestDownloader_CreateDirectBatch(t *testing.T) { _, err := downloader.CreateDirectBatch(&base.CreateTaskBatch{ Reqs: reqs, - Opts: testDownloadOpt, + Opts: newTestDownloadOpt(), }) if err != nil { t.Fatal(err) @@ -266,6 +313,9 @@ func TestDownloader_CreateWithProxy(t *testing.T) { } func doTestDownloaderCreateWithProxy(t *testing.T, auth bool, buildReqProxy func(reqProxy *base.RequestProxy) *base.RequestProxy, buildProxyConfig func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig, errHandler func(err error)) { + listener := test.StartTestFileServer() + defer listener.Close() + usr, pwd := "", "" if auth { usr, pwd = "admin", "123" @@ -291,7 +341,7 @@ func doTestDownloaderCreateWithProxy(t *testing.T, auth bool, buildReqProxy func downloader.cfg.DownloaderStoreConfig.Proxy = globalProxyCfg req := &base.Request{ - URL: test.ExternalDownloadUrl, + URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } if buildReqProxy != nil { req.Proxy = buildReqProxy(&base.RequestProxy{ @@ -310,13 +360,13 @@ func doTestDownloaderCreateWithProxy(t *testing.T, auth bool, buildReqProxy func return } want := &base.Resource{ - Size: test.ExternalDownloadSize, + Size: test.BuildSize, Range: true, Files: []*base.FileInfo{ { - Name: test.ExternalDownloadName, + Name: test.BuildName, Path: "", - Size: test.ExternalDownloadSize, + Size: test.BuildSize, }, }, } @@ -384,7 +434,7 @@ func TestDownloader_StoreAndRestore(t *testing.T) { req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } - rr, err := downloader.Resolve(req, testDownloadOpt) + rr, err := downloader.Resolve(req, newTestDownloadOpt()) if err != nil { t.Fatal(err) } @@ -653,7 +703,7 @@ func TestDownloader_Stats(t *testing.T) { req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } - rr, err := downloader.Resolve(req, testDownloadOpt) + rr, err := downloader.Resolve(req, newTestDownloadOpt()) if err != nil { t.Fatal(err) } @@ -708,7 +758,7 @@ func TestDownloader_Delete(t *testing.T) { req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } - taskId, err := downloader.CreateDirect(req, testDownloadOpt) + taskId, err := downloader.CreateDirect(req, newTestDownloadOpt()) if err != nil { t.Fatal(err) } @@ -772,7 +822,7 @@ func TestDownloader_PauseAndContinue(t *testing.T) { req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } - rr, err := downloader.Resolve(req, testDownloadOpt) + rr, err := downloader.Resolve(req, newTestDownloadOpt()) if err != nil { t.Fatal(err) } @@ -843,7 +893,7 @@ func TestDownloader_PauseAllAndContinueAll(t *testing.T) { req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } - rr, err := downloader.Resolve(req, testDownloadOpt) + rr, err := downloader.Resolve(req, newTestDownloadOpt()) if err != nil { t.Fatal(err) } diff --git a/pkg/download/engine/engine.go b/pkg/download/engine/engine.go index 2355cb8d..b78db85a 100644 --- a/pkg/download/engine/engine.go +++ b/pkg/download/engine/engine.go @@ -3,6 +3,8 @@ package engine import ( _ "embed" "errors" + "fmt" + "sync" "github.com/GopeedLab/gopeed/pkg/base" gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error" @@ -14,7 +16,6 @@ import ( "github.com/dop251/goja" "github.com/dop251/goja_nodejs/eventloop" gojaurl "github.com/dop251/goja_nodejs/url" - "time" ) //go:embed polyfill/out/index.js @@ -26,6 +27,8 @@ type Engine struct { Runtime *goja.Runtime } +type JSFunction func(goja.FunctionCall) goja.Value + // RunString executes the script and returns the go type value // if script result is promise, it will be resolved func (e *Engine) RunString(script string) (value any, err error) { @@ -36,37 +39,88 @@ func (e *Engine) RunString(script string) (value any, err error) { // CallFunction calls the function and returns the go type value // if function result is promise, it will be resolved -func (e *Engine) CallFunction(fn goja.Callable, args ...any) (value any, err error) { +func (e *Engine) CallFunction(fn any, args ...any) (value any, err error) { return e.runOnLoop(func(runtime *goja.Runtime) (goja.Value, error) { - if args == nil { - return fn(nil) - } var jsArgs []goja.Value for _, arg := range args { jsArgs = append(jsArgs, runtime.ToValue(arg)) } - return fn(nil, jsArgs...) + switch f := fn.(type) { + case goja.Callable: + if args == nil { + return f(nil) + } + return f(nil, jsArgs...) + case JSFunction: + return callExportedFunction(func(call goja.FunctionCall) goja.Value { + return f(call) + }, jsArgs...) + default: + return nil, fmt.Errorf("unsupported function type: %T", fn) + } }) } func (e *Engine) runOnLoop(fn func(runtime *goja.Runtime) (goja.Value, error)) (any, error) { type result struct { - value goja.Value + value any err error } ch := make(chan result, 1) ok := e.loop.RunOnLoop(func(runtime *goja.Runtime) { + var once sync.Once + sendResult := func(res result) { + once.Do(func() { + ch <- res + }) + } defer func() { if r := recover(); r != nil { - if err, ok := r.(error); ok { - ch <- result{err: err} - return + switch v := r.(type) { + case error: + sendResult(result{err: v}) + case goja.Value: + sendResult(result{err: exportJSError(v)}) + default: + sendResult(result{err: fmt.Errorf("panic: %v", r)}) } - ch <- result{err: errors.New("panic")} } }() value, err := fn(runtime) - ch <- result{value: value, err: err} + if err != nil { + sendResult(result{err: err}) + return + } + if p, ok := value.Export().(*goja.Promise); ok { + switch p.State() { + case goja.PromiseStateFulfilled: + sendResult(result{value: exportJSValue(p.Result())}) + return + case goja.PromiseStateRejected: + sendResult(result{err: exportJSError(p.Result())}) + return + } + promiseObj := value.ToObject(runtime) + thenVal := promiseObj.Get("then") + thenFn, ok := goja.AssertFunction(thenVal) + if !ok { + sendResult(result{err: errors.New("promise.then is not callable")}) + return + } + onFulfilled := runtime.ToValue(func(call goja.FunctionCall) goja.Value { + sendResult(result{value: exportJSValue(call.Argument(0))}) + return goja.Undefined() + }) + onRejected := runtime.ToValue(func(call goja.FunctionCall) goja.Value { + sendResult(result{err: exportJSError(call.Argument(0))}) + return goja.Undefined() + }) + if _, err := thenFn(promiseObj, onFulfilled, onRejected); err != nil { + sendResult(result{err: err}) + } + return + } + sendResult(result{value: exportJSValue(value)}) }) if !ok { return nil, errors.New("engine loop terminated") @@ -75,7 +129,7 @@ func (e *Engine) runOnLoop(fn func(runtime *goja.Runtime) (goja.Value, error)) ( if res.err != nil { return nil, res.err } - return resolveResult(res.value) + return res.value, nil } func (e *Engine) Close() { @@ -130,7 +184,7 @@ func NewEngine(cfg *Config) *Engine { if _, err := runtime.RunString("global.location = new URL('http://localhost');"); err != nil { return } - if err := stream.Enable(runtime, cfg.StreamConfig); err != nil { + if err := stream.Enable(runtime, loop, cfg.StreamConfig); err != nil { return } return @@ -144,35 +198,45 @@ func Run(script string) (value any, err error) { return engine.RunString(script) } -// if the value is Promise, it will be resolved and return the result. -func resolveResult(value goja.Value) (any, error) { - export := value.Export() - switch export.(type) { - case *goja.Promise: - p := export.(*goja.Promise) - for p.State() == goja.PromiseStatePending { - time.Sleep(time.Millisecond * 10) - } - switch p.State() { - case goja.PromiseStatePending: - return nil, nil - case goja.PromiseStateFulfilled: - return p.Result().Export(), nil - case goja.PromiseStateRejected: - if err, ok := p.Result().Export().(error); ok { - return nil, err - } else { - stack := p.Result().String() - result := p.Result() - if ro, ok := result.(*goja.Object); ok { - stackVal := ro.Get("stack") - if stackVal != nil && stackVal.String() != "" { - stack = stackVal.String() - } - } - return nil, errors.New(stack) - } +func exportJSValue(value goja.Value) any { + if value == nil { + return nil + } + return value.Export() +} + +func exportJSError(value goja.Value) error { + if value == nil || goja.IsUndefined(value) || goja.IsNull(value) { + return errors.New("promise rejected") + } + if err, ok := value.Export().(error); ok { + return err + } + stack := value.String() + if ro, ok := value.(*goja.Object); ok { + stackVal := ro.Get("stack") + if stackVal != nil && stackVal.String() != "" { + stack = stackVal.String() } } - return export, nil + return errors.New(stack) +} + +func callExportedFunction(fn func(goja.FunctionCall) goja.Value, args ...goja.Value) (ret goja.Value, err error) { + defer func() { + if r := recover(); r != nil { + switch v := r.(type) { + case error: + err = v + case goja.Value: + err = exportJSError(v) + default: + err = fmt.Errorf("panic: %v", r) + } + } + }() + return fn(goja.FunctionCall{ + This: nil, + Arguments: args, + }), nil } diff --git a/pkg/download/engine/engine_test.go b/pkg/download/engine/engine_test.go index 3e0775f7..91143fab 100644 --- a/pkg/download/engine/engine_test.go +++ b/pkg/download/engine/engine_test.go @@ -26,7 +26,11 @@ func TestPolyfill(t *testing.T) { doTestPolyfill(t, "MessageError") doTestPolyfill(t, "XMLHttpRequest") doTestPolyfill(t, "Blob") + doTestPolyfill(t, "Event") + doTestPolyfill(t, "EventTarget") doTestPolyfill(t, "FormData") + doTestPolyfill(t, "AbortController") + doTestPolyfill(t, "AbortSignal") doTestPolyfill(t, "TextDecoder") doTestPolyfill(t, "TextEncoder") doTestPolyfill(t, "fetch") @@ -36,13 +40,90 @@ func TestPolyfill(t *testing.T) { func TestError(t *testing.T) { engine := NewEngine(nil) _, err := engine.RunString(` - throw new MessageError('test'); + throw new MessageError('test'); `) if me, ok := gojautil.AssertError[*gojaerror.MessageError](err); !ok { t.Fatalf("expect MessageError, but got %v", me) } } +func TestURLCreateObjectURLRejectsUnsupportedType(t *testing.T) { + engine := NewEngine(nil) + defer engine.Close() + + _, err := engine.RunString(` + URL.createObjectURL({}); + `) + if err == nil { + t.Fatal("expected unsupported type error") + } + if !strings.Contains(err.Error(), "Unsupported object type for URL.createObjectURL: Object") { + t.Fatalf("unexpected unsupported type error: %v", err) + } +} + +func TestCallFunction_DetachedAsyncWorkDoesNotBlockReturn(t *testing.T) { + engine := NewEngine(nil) + defer engine.Close() + if _, err := engine.RunString(` + globalThis.__done = false; + async function testDetached() { + (async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + globalThis.__done = true; + })(); + return "ok"; + } + `); err != nil { + t.Fatal(err) + } + fn, ok := goja.AssertFunction(engine.Runtime.Get("testDetached")) + if !ok { + t.Fatal("testDetached is not callable") + } + startedAt := time.Now() + value, err := engine.CallFunction(fn) + if err != nil { + t.Fatal(err) + } + elapsed := time.Since(startedAt) + if elapsed > 200*time.Millisecond { + t.Fatalf("detached async work blocked return for %s", elapsed) + } + if value != "ok" { + t.Fatalf("unexpected return value: %#v", value) + } +} + +func TestCallFunction_AwaitedPromiseBlocksUntilSettled(t *testing.T) { + engine := NewEngine(nil) + defer engine.Close() + if _, err := engine.RunString(` + async function testAwaited() { + await new Promise((resolve) => setTimeout(resolve, 120)); + return "ok"; + } + `); err != nil { + t.Fatal(err) + } + fn, ok := goja.AssertFunction(engine.Runtime.Get("testAwaited")) + if !ok { + t.Fatal("testAwaited is not callable") + } + startedAt := time.Now() + value, err := engine.CallFunction(fn) + if err != nil { + t.Fatal(err) + } + elapsed := time.Since(startedAt) + if elapsed < 100*time.Millisecond { + t.Fatalf("awaited promise returned too early: %s", elapsed) + } + if value != "ok" { + t.Fatalf("unexpected return value: %#v", value) + } +} + func TestFetch(t *testing.T) { server := startServer() defer server.Close() @@ -64,6 +145,50 @@ async function testText(){ return await resp.text(); } +async function testResponseBodyGetReader() { + const resp = await fetch(host+'/text', { + method: 'POST', + body: 'stream-body' + }); + if (!resp.body || typeof resp.body.getReader !== 'function') { + throw new Error('response.body.getReader is not available'); + } + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let result = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + result += decoder.decode(value, { stream: true }); + } + result += decoder.decode(); + return result; +} + +async function testStreamingFetchResponseBody() { + const startedAt = Date.now(); + const resp = await fetch(host+'/stream'); + const resolvedAt = Date.now() - startedAt; + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + const first = await reader.read(); + const firstAt = Date.now() - startedAt; + const second = await reader.read(); + const secondAt = Date.now() - startedAt; + let result = ''; + result += decoder.decode(first.value, { stream: true }); + result += decoder.decode(second.value, { stream: true }); + result += decoder.decode(); + return { + resolvedAt, + firstAt, + secondAt, + text: result + }; +} + async function testOctetStream(file){ const resp = await fetch(host+'/octetStream',{ method: 'POST', @@ -251,6 +376,32 @@ async function testFingerprintSafari(){ t.Fatalf("testText failed, want %s, got %s", "test", result) } + result, err = callTestFun(engine, "testResponseBodyGetReader") + if err != nil { + t.Fatal(err) + } + if result != "stream-body" { + t.Fatalf("testResponseBodyGetReader failed, want %s, got %s", "stream-body", result) + } + + result, err = callTestFun(engine, "testStreamingFetchResponseBody") + if err != nil { + t.Fatal(err) + } + streaming := result.(map[string]any) + if streaming["text"] != "chunk-1chunk-2" { + t.Fatalf("testStreamingFetchResponseBody failed, want %s, got %v", "chunk-1chunk-2", streaming["text"]) + } + if int(streaming["resolvedAt"].(int64)) >= 120 { + t.Fatalf("fetch resolved too late, got %dms", streaming["resolvedAt"].(int64)) + } + if int(streaming["firstAt"].(int64)) >= 120 { + t.Fatalf("first chunk arrived too late, got %dms", streaming["firstAt"].(int64)) + } + if int(streaming["secondAt"].(int64)) < 120 { + t.Fatalf("second chunk arrived too early, got %dms", streaming["secondAt"].(int64)) + } + func() { jsFile, _, md5 := buildFile(t, engine.Runtime) result, err = callTestFun(engine, "testOctetStream", jsFile) @@ -464,6 +615,18 @@ func startServer() net.Listener { w.WriteHeader(http.StatusOK) w.Write(buf) }) + mux.HandleFunc("/stream", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if flusher, ok := w.(http.Flusher); ok { + _, _ = w.Write([]byte("chunk-1")) + flusher.Flush() + time.Sleep(150 * time.Millisecond) + _, _ = w.Write([]byte("chunk-2")) + flusher.Flush() + return + } + _, _ = w.Write([]byte("chunk-1chunk-2")) + }) mux.HandleFunc("/octetStream", func(w http.ResponseWriter, r *http.Request) { md5 := calcMd5(r.Body) w.WriteHeader(http.StatusOK) diff --git a/pkg/download/engine/inject/stream/module.go b/pkg/download/engine/inject/stream/module.go index a0d63be3..fd9ba6b7 100644 --- a/pkg/download/engine/inject/stream/module.go +++ b/pkg/download/engine/inject/stream/module.go @@ -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() +} diff --git a/pkg/download/engine/inject/stream/stream.js b/pkg/download/engine/inject/stream/stream.js index 5492ddfd..86cf887c 100644 --- a/pkg/download/engine/inject/stream/stream.js +++ b/pkg/download/engine/inject/stream/stream.js @@ -5,6 +5,10 @@ const closeWritableObjectURL = globalThis.__gopeed_close_writable_stream_object_url; const abortWritableObjectURL = globalThis.__gopeed_abort_writable_stream_object_url; const revokeObjectURL = globalThis.__gopeed_revoke_object_url; + const fetchOpen = globalThis.__gopeed_fetch_open; + const fetchRead = globalThis.__gopeed_fetch_read; + const fetchClose = globalThis.__gopeed_fetch_close; + const fetchAbort = globalThis.__gopeed_fetch_abort; if (typeof globalThis.ReadableStream === "undefined") { class ReadableStreamDefaultController { @@ -34,6 +38,13 @@ return this._stream._read(); } + cancel(reason) { + if (!this._stream) { + return Promise.resolve(); + } + return this._stream.cancel(reason); + } + releaseLock() { if (this._stream) { this._stream._reader = null; @@ -45,15 +56,17 @@ class ReadableStream { constructor(source = {}) { + this._source = source; this._queue = []; this._waiters = []; this._closed = false; this._errored = null; this._reader = null; + this._pulling = false; this.locked = false; - const controller = new ReadableStreamDefaultController(this); + this._controller = new ReadableStreamDefaultController(this); if (typeof source.start === "function") { - source.start(controller); + source.start(this._controller); } } @@ -87,6 +100,14 @@ if (this._errored) { return Promise.reject(this._errored); } + this._markBodyUsed(); + if (this._queue.length > 0) { + return Promise.resolve({ done: false, value: this._queue.shift() }); + } + if (this._closed) { + return Promise.resolve({ done: true, value: undefined }); + } + this._maybePull(); if (this._queue.length > 0) { return Promise.resolve({ done: false, value: this._queue.shift() }); } @@ -95,9 +116,39 @@ } return new Promise((resolve, reject) => { this._waiters.push({ resolve, reject }); + this._maybePull(); }); } + _maybePull() { + if (this._closed || this._errored || this._pulling) { + return; + } + if (this._queue.length > 0) { + return; + } + if (!this._source || typeof this._source.pull !== "function") { + return; + } + this._pulling = true; + Promise.resolve(this._source.pull(this._controller)) + .catch((err) => { + this._error(err); + }) + .finally(() => { + this._pulling = false; + if (!this._closed && !this._errored && this._queue.length === 0 && this._waiters.length > 0) { + this._maybePull(); + } + }); + } + + _markBodyUsed() { + if (typeof this.__gopeedMarkBodyUsed === "function") { + this.__gopeedMarkBodyUsed(); + } + } + getReader() { if (this.locked) { throw new TypeError("ReadableStream is locked"); @@ -107,6 +158,17 @@ return this._reader; } + cancel(reason) { + this._markBodyUsed(); + if (this._source && typeof this._source.cancel === "function") { + return Promise.resolve(this._source.cancel(reason)).then(() => { + this._close(); + }); + } + this._close(); + return Promise.resolve(); + } + async pipeTo(dest) { const reader = this.getReader(); const writer = dest.getWriter(); @@ -293,44 +355,436 @@ }; } + function toUint8Array(chunk) { + if (chunk == null) { + return new Uint8Array(0); + } + if (chunk instanceof Uint8Array) { + return chunk; + } + if (typeof chunk === "string") { + return new TextEncoder().encode(chunk); + } + if (chunk instanceof ArrayBuffer) { + return new Uint8Array(chunk); + } + if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView && ArrayBuffer.isView(chunk)) { + return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); + } + if (typeof Blob !== "undefined" && chunk instanceof Blob) { + if (chunk._buffer instanceof Uint8Array) { + return chunk._buffer; + } + } + return new Uint8Array(0); + } + + function createBodyReadableStream(owner) { + return new ReadableStream({ + start(controller) { + Promise.resolve().then(async () => { + owner.bodyUsed = true; + if (owner._noBody) { + controller.close(); + return; + } + if (owner._bodyArrayBuffer) { + controller.enqueue(toUint8Array(owner._bodyArrayBuffer)); + controller.close(); + return; + } + if (owner._bodyBlob) { + const data = await owner._bodyBlob.arrayBuffer(); + controller.enqueue(new Uint8Array(data)); + controller.close(); + return; + } + if (owner._bodyText != null) { + controller.enqueue(toUint8Array(owner._bodyText)); + controller.close(); + return; + } + if (owner._bodyInit != null) { + controller.enqueue(toUint8Array(owner._bodyInit)); + } + controller.close(); + }).catch((err) => { + controller.error(err); + }); + } + }); + } + + async function readAllFromStream(stream, asText) { + if (!stream) { + return asText ? "" : new Uint8Array(0); + } + const reader = stream.getReader(); + const chunks = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + const chunk = toUint8Array(value); + chunks.push(chunk); + total += chunk.byteLength; + } + } finally { + reader.releaseLock(); + } + const merged = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.byteLength; + } + if (asText) { + return new TextDecoder().decode(merged); + } + return merged; + } + + function createStreamBackedBlob(stream, contentType, size) { + const blob = new Blob([], { type: contentType || "" }); + if (Number.isFinite(size) && size >= 0) { + try { + Object.defineProperty(blob, "size", { + configurable: true, + enumerable: true, + value: size, + }); + } catch (_) { + } + } + blob.__gopeedStreamBackedBlob = true; + blob.__gopeedBodyStream = stream; + blob.stream = function () { + const bodyStream = this.__gopeedBodyStream; + this.__gopeedBodyStream = null; + if (bodyStream instanceof ReadableStream) { + return bodyStream; + } + return new Blob([], { type: this.type || "" }).stream(); + }; + blob.arrayBuffer = async function () { + const bytes = await readAllFromStream(this.stream(), false); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + }; + blob.text = async function () { + return readAllFromStream(this.stream(), true); + }; + return blob; + } + + function attachResponseStreaming(response, stream) { + response.__gopeedBodyStream = stream; + response.__gopeedBodyConsumed = false; + const ensureUnused = () => { + if (response.__gopeedBodyConsumed) { + throw new TypeError("Already read"); + } + }; + const markBodyUsed = () => { + ensureUnused(); + response.__gopeedBodyConsumed = true; + response.bodyUsed = true; + }; + if (stream) { + stream.__gopeedMarkBodyUsed = () => { + if (!response.__gopeedBodyConsumed) { + response.__gopeedBodyConsumed = true; + response.bodyUsed = true; + } + }; + } + response.text = async function () { + ensureUnused(); + markBodyUsed(); + return readAllFromStream(stream, true); + }; + response.arrayBuffer = async function () { + ensureUnused(); + markBodyUsed(); + const bytes = await readAllFromStream(stream, false); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + }; + response.blob = async function () { + ensureUnused(); + markBodyUsed(); + const contentType = this.headers && this.headers.get ? (this.headers.get("content-type") || "") : ""; + const contentLength = this.headers && this.headers.get ? parseInt(this.headers.get("content-length") || "", 10) : NaN; + return createStreamBackedBlob(stream, contentType, contentLength); + }; + response.json = async function () { + const text = await this.text(); + return JSON.parse(text); + }; + return response; + } + + if (typeof globalThis.Response === "function") { + const responseProto = globalThis.Response.prototype; + const bodyDescriptor = Object.getOwnPropertyDescriptor(responseProto, "body"); + if (!bodyDescriptor || typeof bodyDescriptor.get !== "function") { + Object.defineProperty(responseProto, "body", { + configurable: true, + enumerable: true, + get() { + if (this.__gopeedBodyStream) { + return this.__gopeedBodyStream; + } + if (!this.__gopeedBodyStream) { + this.__gopeedBodyStream = createBodyReadableStream(this); + } + return this.__gopeedBodyStream; + } + }); + } + } + const originalCreateObjectURL = typeof URL.createObjectURL === "function" ? URL.createObjectURL.bind(URL) : null; const originalRevokeObjectURL = typeof URL.revokeObjectURL === "function" ? URL.revokeObjectURL.bind(URL) : null; + const resumableReadableObjectURLs = new Map(); + const activeReadableObjectURLs = new Map(); + const readableObjectURLYieldBytes = 256 * 1024; + + function isIgnorableGBlobObjectURLError(error) { + const message = error == null ? "" : String(error && error.message ? error.message : error); + return message.indexOf("gblob source revoked") >= 0 || + message.indexOf("gblob source not found") >= 0 || + message.indexOf("gblob source closed") >= 0 || + message.indexOf("gblob source aborted") >= 0; + } + + function getValueTypeName(value) { + if (value === null) { + return "null"; + } + if (value === undefined) { + return "undefined"; + } + if (value && value.constructor && typeof value.constructor.name === "string" && value.constructor.name) { + return value.constructor.name; + } + return typeof value; + } + + function toReadableStreamReader(value, sourceLabel) { + if (value instanceof ReadableStream) { + return value.getReader(); + } + throw new TypeError(sourceLabel + " must return a ReadableStream, got " + getValueTypeName(value)); + } + + function describeObjectURLValue(value) { + if (value && value.__gopeedStreamBackedBlob) { + const stream = value.__gopeedBodyStream; + value.__gopeedBodyStream = null; + if (stream instanceof ReadableStream) { + return { + kind: "readable", + initialReadable: stream, + openReadable: null, + sourceLabel: "URL.createObjectURL Response blob stream", + }; + } + } + if (value instanceof Blob) { + return { + kind: "blob", + value, + }; + } + if (value instanceof ReadableStream) { + return { + kind: "readable", + initialReadable: value, + openReadable: null, + sourceLabel: "URL.createObjectURL ReadableStream", + }; + } + if (typeof value === "function") { + return { + kind: "opener", + initialReadable: null, + openReadable: value, + sourceLabel: "URL.createObjectURL opener function", + }; + } + return { + kind: "other", + value, + }; + } + + function startReadableObjectURL(url, state) { + activeReadableObjectURLs.set(url, state); + setTimeout(() => { + void pumpReadableObjectURL(url, state); + }, 0); + } + + function releaseReader(reader, reason) { + if (!reader) { + return; + } + try { + if (reason !== undefined && typeof reader.cancel === "function") { + const result = reader.cancel(reason); + if (result && typeof result.catch === "function") { + result.catch(function () {}); + } + } + } catch (_) { + } + if (typeof reader.releaseLock === "function") { + try { + reader.releaseLock(); + } catch (_) { + } + } + } + + function releaseActiveReadable(url, reason) { + const active = activeReadableObjectURLs.get(url); + if (!active) { + return; + } + activeReadableObjectURLs.delete(url); + active.cancelled = true; + releaseReader(active.reader, reason); + } + + async function pumpReadableObjectURL(url, state) { + let reader = null; + try { + let source; + if (state.initialReadable) { + source = state.initialReadable; + state.initialReadable = null; + } else { + if (typeof state.openReadable !== "function") { + throw new Error("gblob readable stream is not reopenable"); + } + source = await state.openReadable(state.offset); + } + reader = toReadableStreamReader(source, state.sourceLabel); + if (activeReadableObjectURLs.get(url) !== state || state.cancelled) { + releaseReader(reader, "stale gblob producer"); + reader = null; + return; + } + state.reader = reader; + let bytesSinceYield = 0; + while (!state.cancelled) { + const current = activeReadableObjectURLs.get(url); + if (current !== state) { + releaseReader(reader, "stale gblob producer"); + reader = null; + return; + } + const { done, value } = await reader.read(); + if (done) { + if (activeReadableObjectURLs.get(url) === state) { + activeReadableObjectURLs.delete(url); + await closeWritableObjectURL(url); + } + releaseReader(reader); + reader = null; + return; + } + const chunk = value instanceof Uint8Array ? value : new Uint8Array(value); + if (activeReadableObjectURLs.get(url) !== state || state.cancelled) { + releaseReader(reader, "stale gblob producer"); + reader = null; + return; + } + await writeWritableObjectURL(url, chunk); + bytesSinceYield += chunk.byteLength; + if (bytesSinceYield >= readableObjectURLYieldBytes) { + bytesSinceYield = 0; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + releaseReader(reader, "gblob producer cancelled"); + reader = null; + } catch (error) { + if (isIgnorableGBlobObjectURLError(error)) { + if (activeReadableObjectURLs.get(url) === state) { + activeReadableObjectURLs.delete(url); + } + if (reader) { + releaseReader(reader, "gblob source closed"); + reader = null; + } + return; + } + if (activeReadableObjectURLs.get(url) === state) { + activeReadableObjectURLs.delete(url); + try { + await abortWritableObjectURL(url, error == null ? "" : String(error && error.message ? error.message : error)); + } catch (_) { + } + } + if (reader) { + releaseReader(reader, error); + reader = null; + } + } + } + + globalThis.__gopeed_open_writable_stream_object_url = function (url, offset) { + const openReadable = resumableReadableObjectURLs.get(url); + if (typeof openReadable !== "function") { + throw new Error("gblob resumable opener not found"); + } + releaseActiveReadable(url, "gblob source reopened"); + startReadableObjectURL(url, { + initialReadable: null, + openReadable, + sourceLabel: "URL.createObjectURL opener function", + offset: Number(offset) || 0, + reader: null, + cancelled: false, + }); + }; URL.createObjectURL = function (value) { - if (value instanceof Blob) { - return createBlobObjectURL(value._buffer, value.type || ""); + const described = describeObjectURLValue(value); + if (described.kind === "blob") { + return createBlobObjectURL(described.value._buffer, described.value.type || ""); } - if (value instanceof WritableStream) { - if (value.__gopeedObjectURL) { - return value.__gopeedObjectURL; - } - const url = createWritableObjectURL(); - value._addObserver({ - write(chunk) { - return Promise.resolve(writeWritableObjectURL(url, chunk)); - }, - close() { - return Promise.resolve(closeWritableObjectURL(url)); - }, - abort(reason) { - return Promise.resolve(abortWritableObjectURL(url, reason == null ? "" : String(reason))); - } + if (described.kind === "readable") { + const url = createWritableObjectURL(false); + startReadableObjectURL(url, { + initialReadable: described.initialReadable, + openReadable: null, + sourceLabel: described.sourceLabel, + offset: 0, + reader: null, + cancelled: false, }); - value.__gopeedObjectURL = url; return url; } - if (originalCreateObjectURL) { - return originalCreateObjectURL(value); + if (described.kind === "opener") { + const url = createWritableObjectURL(true); + resumableReadableObjectURLs.set(url, described.openReadable); + return url; } - throw new TypeError("Unsupported object type"); + throw new TypeError("Unsupported object type for URL.createObjectURL: " + getValueTypeName(described.value) + ". Expected Blob, ReadableStream, or opener function"); }; URL.revokeObjectURL = function (url) { if (typeof url === "string" && url.indexOf("gblob:") === 0) { + resumableReadableObjectURLs.delete(url); + releaseActiveReadable(url, "gblob source revoked"); revokeObjectURL(url); return; } @@ -338,4 +792,83 @@ originalRevokeObjectURL(url); } }; + + const originalFetch = typeof globalThis.fetch === "function" + ? globalThis.fetch.bind(globalThis) + : null; + + if (typeof fetchOpen === "function") { + globalThis.fetch = async function (input, init) { + const request = new Request(input, init); + if (originalFetch && (request.method === "HEAD" || request.redirect === "manual" || request.redirect === "error")) { + return originalFetch(input, init); + } + let body = null; + if (request._bodyFormData) { + body = request._bodyFormData; + } else if (request._bodyArrayBuffer) { + body = request._bodyArrayBuffer; + } else if (request._bodyBlob) { + body = await request._bodyBlob.arrayBuffer(); + } else if (request._bodyInit != null && typeof request._bodyInit === "object") { + body = request._bodyInit; + } else if (request._bodyText != null) { + body = request._bodyText; + } else if (request._bodyInit != null) { + body = request._bodyInit; + } + const headers = []; + request.headers.forEach((value, key) => { + headers.push([key, value]); + }); + let meta; + try { + meta = fetchOpen({ + url: request.url, + method: request.method, + headers, + body, + redirect: request.redirect, + credentials: request.credentials + }); + } catch (error) { + throw error instanceof Error ? error : new TypeError(String(error)); + } + const stream = new ReadableStream({ + pull(controller) { + let chunk; + try { + chunk = fetchRead(meta.id, 64 * 1024); + } catch (error) { + fetchClose(meta.id); + controller.error(error instanceof Error ? error : new TypeError(String(error))); + return; + } + if (chunk == null) { + fetchClose(meta.id); + controller.close(); + return; + } + const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk); + if (bytes.byteLength === 0) { + fetchClose(meta.id); + controller.close(); + return; + } + controller.enqueue(bytes); + }, + cancel(reason) { + fetchAbort(meta.id, reason == null ? "" : String(reason)); + } + }); + const response = new Response(null, { + status: meta.status, + statusText: meta.statusText, + headers: meta.headers, + url: meta.url + }); + return attachResponseStreaming(response, stream); + }; + globalThis.fetch.__gopeedOriginalFetch = originalFetch; + } })(); diff --git a/pkg/download/engine/inject/vm/module.go b/pkg/download/engine/inject/vm/module.go index b136d3d8..7b33df98 100644 --- a/pkg/download/engine/inject/vm/module.go +++ b/pkg/download/engine/inject/vm/module.go @@ -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) + } } }() diff --git a/pkg/download/engine/polyfill/out/index.js b/pkg/download/engine/polyfill/out/index.js index f9699e4d..916e3edd 100644 --- a/pkg/download/engine/polyfill/out/index.js +++ b/pkg/download/engine/polyfill/out/index.js @@ -1 +1 @@ -(()=>{var e={725:function(e,t,r){var o,n,i;i="undefined"!=typeof self&&self||"undefined"!=typeof window&&window||void 0!==r.g&&r.g||this,o=function(e){"use strict";var t=i.BlobBuilder||i.WebKitBlobBuilder||i.MSBlobBuilder||i.MozBlobBuilder,r=i.URL||i.webkitURL||function(e,t){return(t=document.createElement("a")).href=e,t},o=i.Blob,n=r.createObjectURL,a=r.revokeObjectURL,s=i.Symbol&&i.Symbol.toStringTag,u=!1,f=!1,l=t&&t.prototype.append&&t.prototype.getBlob;try{u=2===new Blob(["ä"]).size,f=2===new Blob([new Uint8Array([1,2])]).size}catch(e){}function c(e){return e.map((function(e){if(e.buffer instanceof ArrayBuffer){var t=e.buffer;if(e.byteLength!==t.byteLength){var r=new Uint8Array(e.byteLength);r.set(new Uint8Array(t,e.byteOffset,e.byteLength)),t=r.buffer}return t}return e}))}function h(e,r){r=r||{};var o=new t;return c(e).forEach((function(e){o.append(e)})),r.type?o.getBlob(r.type):o.getBlob()}function d(e,t){return new o(c(e),t||{})}i.Blob&&(h.prototype=Blob.prototype,d.prototype=Blob.prototype);var p="function"==typeof TextEncoder?TextEncoder.prototype.encode.bind(new TextEncoder):function(e){for(var t=0,r=e.length,o=i.Uint8Array||Array,n=0,a=Math.max(32,r+(r>>1)+7),s=new o(a>>3<<3);t=55296&&u<=56319){if(t=55296&&u<=56319)continue}if(n+4>s.length){a+=8,a=(a*=1+t/e.length*2)>>3<<3;var l=new Uint8Array(a);l.set(s),s=l}if(0!=(4294967168&u)){if(0==(4294965248&u))s[n++]=u>>6&31|192;else if(0==(4294901760&u))s[n++]=u>>12&15|224,s[n++]=u>>6&63|128;else{if(0!=(4292870144&u))continue;s[n++]=u>>18&7|240,s[n++]=u>>12&63|128,s[n++]=u>>6&63|128}s[n++]=63&u|128}else s[n++]=u}return s.slice(0,n)},y="function"==typeof TextDecoder?TextDecoder.prototype.decode.bind(new TextDecoder):function(e){for(var t=e.length,r=[],o=0;o239?4:u>223?3:u>191?2:1;if(o+l<=t)switch(l){case 1:u<128&&(f=u);break;case 2:128==(192&(n=e[o+1]))&&(s=(31&u)<<6|63&n)>127&&(f=s);break;case 3:n=e[o+1],i=e[o+2],128==(192&n)&&128==(192&i)&&(s=(15&u)<<12|(63&n)<<6|63&i)>2047&&(s<55296||s>57343)&&(f=s);break;case 4:n=e[o+1],i=e[o+2],a=e[o+3],128==(192&n)&&128==(192&i)&&128==(192&a)&&(s=(15&u)<<18|(63&n)<<12|(63&i)<<6|63&a)>65535&&s<1114112&&(f=s)}null===f?(f=65533,l=1):f>65535&&(f-=65536,r.push(f>>>10&1023|55296),f=56320|1023&f),r.push(f),o+=l}for(var c=r.length,h="",d=0;d>2,l=(3&n)<<4|a>>4,c=(15&a)<<2|u>>6,h=63&u;s||(h=64,i||(c=64)),r.push(t[f],t[l],t[c],t[h])}return r.join("")}var s=Object.create||function(e){function t(){}return t.prototype=e,new t};function u(e){return Object.prototype.toString.call(e).slice(8,-1)}function f(e,t){return"object"==typeof e&&Object.prototype.isPrototypeOf.call(e.prototype,t)}var l=["Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Uint16Array","Int32Array","Uint32Array","Float32Array","Float64Array","ArrayBuffer"];function c(e){return t=l,r=u(e),-1!==t.indexOf(r)||f(i.ArrayBuffer,e);var t,r}function h(e,r){r=null==r?{}:r;for(var o=0,n=(e=e?e.slice():[]).length;o=t.size&&r.close()}))}})}}catch(e){try{new ReadableStream({}),w=function(e){var t=0;return new ReadableStream({pull:function(r){return e.slice(t,t+524288).arrayBuffer().then((function(o){t+=o.byteLength;var n=new Uint8Array(o);r.enqueue(n),t==e.size&&r.close()}))}})}}catch(e){try{new Response("").body.getReader().read(),w=function(){return new Response(this).body}}catch(e){w=function(){throw new Error("Include https://github.com/MattiasBuelens/web-streams-polyfill")}}}}function v(e){return new Promise((function(t,r){e.onload=e.onerror=function(o){e.onload=e.onerror=null,"load"===o.type?t(e.result||e):r(new Error("Failed to read the blob/file"))}}))}m.arrayBuffer||(m.arrayBuffer=function(){var e=new FileReader;return e.readAsArrayBuffer(this),v(e)}),m.text||(m.text=function(){var e=new FileReader;return e.readAsText(this),v(e)}),m.stream||(m.stream=w)},void 0===(n=o.apply(t,[t]))||(e.exports=n)},294:function(e,t,r){"use strict";!function(e){function t(){}function r(){}var o=String.fromCharCode,n={}.toString,i=n.call(e.SharedArrayBuffer),a=n(),s=e.Uint8Array,u=s||Array,f=s?ArrayBuffer:u,l=f.isView||function(e){return e&&"length"in e},c=n.call(f.prototype);f=r.prototype;var h=e.TextEncoder,d=new(s?Uint16Array:u)(32);t.prototype.decode=function(e){if(!l(e)){var t=n.call(e);if(t!==c&&t!==i&&t!==a)throw TypeError("Failed to execute 'decode' on 'TextDecoder': The provided value is not of type '(ArrayBuffer or ArrayBufferView)'");e=s?new u(e):e||[]}for(var r,f,h,p=t="",y=0,b=0|e.length,w=b-32|0,m=0,v=0,g=0,A=-1;y>4){case 15:if(2!=(h=255&e[y=y+1|0])>>6||247>6?v+4|0:24,f=f+256&768;case 13:case 12:m<<=6,m|=(31&f)<<6|63&(h=255&e[y=y+1|0]),v=v+7|0,y>6&&m>>v&&1114112>m?(f=m,0<=(m=m-65536|0)&&(A=55296+(m>>10)|0,f=56320+(1023&m)|0,31>g?(d[g]=A,g=g+1|0,A=-1):(h=A,A=f,f=h))):(y=y-(f>>=8)-1|0,f=65533),m=v=0,r=y<=w?32:b-y|0;default:d[g]=f;continue;case 11:case 10:case 9:case 8:}d[g]=65533}if(p+=o(d[0],d[1],d[2],d[3],d[4],d[5],d[6],d[7],d[8],d[9],d[10],d[11],d[12],d[13],d[14],d[15],d[16],d[17],d[18],d[19],d[20],d[21],d[22],d[23],d[24],d[25],d[26],d[27],d[28],d[29],d[30],d[31]),32>g&&(p=p.slice(0,g-32|0)),y>>31,A=-1,p.length=a)o[n]=a;else{if(2047>=a)o[n]=192|a>>6;else{e:{if(55296<=a)if(56319>=a){var f=0|e.charCodeAt(t=t+1|0);if(56320<=f&&57343>=f){if(65535<(a=(a<<10)+f-56613888|0)){o[n]=240|a>>18,o[n=n+1|0]=128|a>>12&63,o[n=n+1|0]=128|a>>6&63,o[n=n+1|0]=128|63&a;continue}break e}a=65533}else 57343>=a&&(a=65533);!i&&t<<1>12,o[n=n+1|0]=128|a>>6&63}o[n=n+1|0]=128|63&a}}return s?o.subarray(0,n):o.slice(0,n)},h||(e.TextDecoder=t,e.TextEncoder=r)}(""+void 0==typeof r.g?""+void 0==typeof self?this:self:r.g)}},t={};function r(o){var n=t[o];if(void 0!==n)return n.exports;var i=t[o]={exports:{}};return e[o].call(i.exports,i,i.exports,r),i.exports}r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),(()=>{"use strict";var e=r(725);globalThis.Blob=e.Blob,globalThis.crypto={getRandomValues(e){for(let t=0,r=e.length;t(e^this.getRandomValues(new Uint8Array(1))[0]&15>>e/4).toString(16)))}},r(294);var t="undefined"!=typeof globalThis&&globalThis||"undefined"!=typeof self&&self||void 0!==r.g&&r.g||{},o={searchParams:"URLSearchParams"in t,iterable:"Symbol"in t&&"iterator"in Symbol,blob:"FileReader"in t&&"Blob"in t&&function(){try{return new Blob,!0}catch(e){return!1}}(),formData:"FormData"in t,arrayBuffer:"ArrayBuffer"in t};if(o.arrayBuffer)var n=["[object Int8Array]","[object Uint8Array]","[object Uint8ClampedArray]","[object Int16Array]","[object Uint16Array]","[object Int32Array]","[object Uint32Array]","[object Float32Array]","[object Float64Array]"],i=ArrayBuffer.isView||function(e){return e&&n.indexOf(Object.prototype.toString.call(e))>-1};function a(e){if("string"!=typeof e&&(e=String(e)),/[^a-z0-9\-#$%&'*+.^_`|~!]/i.test(e)||""===e)throw new TypeError('Invalid character in header field name: "'+e+'"');return e.toLowerCase()}function s(e){return"string"!=typeof e&&(e=String(e)),e}function u(e){var t={next:function(){var t=e.shift();return{done:void 0===t,value:t}}};return o.iterable&&(t[Symbol.iterator]=function(){return t}),t}function f(e){this.map={},e instanceof f?e.forEach((function(e,t){this.append(t,e)}),this):Array.isArray(e)?e.forEach((function(e){if(2!=e.length)throw new TypeError("Headers constructor: expected name/value pair to be length 2, found"+e.length);this.append(e[0],e[1])}),this):e&&Object.getOwnPropertyNames(e).forEach((function(t){this.append(t,e[t])}),this)}function l(e){if(!e._noBody)return e.bodyUsed?Promise.reject(new TypeError("Already read")):void(e.bodyUsed=!0)}function c(e){return new Promise((function(t,r){e.onload=function(){t(e.result)},e.onerror=function(){r(e.error)}}))}function h(e){var t=new FileReader,r=c(t);return t.readAsArrayBuffer(e),r}function d(e){if(e.slice)return e.slice(0);var t=new Uint8Array(e.byteLength);return t.set(new Uint8Array(e)),t.buffer}function p(){return this.bodyUsed=!1,this._initBody=function(e){var t;this.bodyUsed=this.bodyUsed,this._bodyInit=e,e?"string"==typeof e?this._bodyText=e:o.blob&&Blob.prototype.isPrototypeOf(e)?this._bodyBlob=e:o.formData&&FormData.prototype.isPrototypeOf(e)?this._bodyFormData=e:o.searchParams&&URLSearchParams.prototype.isPrototypeOf(e)?this._bodyText=e.toString():o.arrayBuffer&&o.blob&&(t=e)&&DataView.prototype.isPrototypeOf(t)?(this._bodyArrayBuffer=d(e.buffer),this._bodyInit=new Blob([this._bodyArrayBuffer])):o.arrayBuffer&&(ArrayBuffer.prototype.isPrototypeOf(e)||i(e))?this._bodyArrayBuffer=d(e):this._bodyText=e=Object.prototype.toString.call(e):(this._noBody=!0,this._bodyText=""),this.headers.get("content-type")||("string"==typeof e?this.headers.set("content-type","text/plain;charset=UTF-8"):this._bodyBlob&&this._bodyBlob.type?this.headers.set("content-type",this._bodyBlob.type):o.searchParams&&URLSearchParams.prototype.isPrototypeOf(e)&&this.headers.set("content-type","application/x-www-form-urlencoded;charset=UTF-8"))},o.blob&&(this.blob=function(){var e=l(this);if(e)return e;if(this._bodyBlob)return Promise.resolve(this._bodyBlob);if(this._bodyArrayBuffer)return Promise.resolve(new Blob([this._bodyArrayBuffer]));if(this._bodyFormData)throw new Error("could not read FormData body as blob");return Promise.resolve(new Blob([this._bodyText]))}),this.arrayBuffer=function(){if(this._bodyArrayBuffer)return l(this)||(ArrayBuffer.isView(this._bodyArrayBuffer)?Promise.resolve(this._bodyArrayBuffer.buffer.slice(this._bodyArrayBuffer.byteOffset,this._bodyArrayBuffer.byteOffset+this._bodyArrayBuffer.byteLength)):Promise.resolve(this._bodyArrayBuffer));if(o.blob)return this.blob().then(h);throw new Error("could not read as ArrayBuffer")},this.text=function(){var e,t,r,o,n,i=l(this);if(i)return i;if(this._bodyBlob)return e=this._bodyBlob,r=c(t=new FileReader),n=(o=/charset=([A-Za-z0-9_-]+)/.exec(e.type))?o[1]:"utf-8",t.readAsText(e,n),r;if(this._bodyArrayBuffer)return Promise.resolve(function(e){for(var t=new Uint8Array(e),r=new Array(t.length),o=0;o-1?n:o),this.mode=r.mode||this.mode||null,this.signal=r.signal||this.signal||function(){if("AbortController"in t)return(new AbortController).signal}(),this.referrer=null,this.redirect=r.redirect||"follow",("GET"===this.method||"HEAD"===this.method)&&i)throw new TypeError("Body not allowed for GET or HEAD requests");if(this._initBody(i),!("GET"!==this.method&&"HEAD"!==this.method||"no-store"!==r.cache&&"no-cache"!==r.cache)){var a=/([?&])_=[^&]*/;a.test(this.url)?this.url=this.url.replace(a,"$1_="+(new Date).getTime()):this.url+=(/\?/.test(this.url)?"&":"?")+"_="+(new Date).getTime()}}function w(e){var t=new FormData;return e.trim().split("&").forEach((function(e){if(e){var r=e.split("="),o=r.shift().replace(/\+/g," "),n=r.join("=").replace(/\+/g," ");t.append(decodeURIComponent(o),decodeURIComponent(n))}})),t}function m(e,t){if(!(this instanceof m))throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.');if(t||(t={}),this.type="default",this.status=void 0===t.status?200:t.status,this.status<200||this.status>599)throw new RangeError("Failed to construct 'Response': The status provided (0) is outside the range [200, 599].");this.ok=this.status>=200&&this.status<300,this.statusText=void 0===t.statusText?"":""+t.statusText,this.headers=new f(t.headers),this.url=t.url||"",this._initBody(e)}b.prototype.clone=function(){return new b(this,{body:this._bodyInit})},p.call(b.prototype),p.call(m.prototype),m.prototype.clone=function(){return new m(this._bodyInit,{status:this.status,statusText:this.statusText,headers:new f(this.headers),url:this.url})},m.error=function(){var e=new m(null,{status:200,statusText:""});return e.ok=!1,e.status=0,e.type="error",e};var v=[301,302,303,307,308];m.redirect=function(e,t){if(-1===v.indexOf(t))throw new RangeError("Invalid status code");return new m(null,{status:t,headers:{location:e}})};var g=t.DOMException;try{new g}catch(e){(g=function(e,t){this.message=e,this.name=t;var r=Error(e);this.stack=r.stack}).prototype=Object.create(Error.prototype),g.prototype.constructor=g}function A(e,r){return new Promise((function(n,i){var u=new b(e,r);if(u.signal&&u.signal.aborted)return i(new g("Aborted","AbortError"));var l=new XMLHttpRequest;function c(){l.abort()}if(l.onload=function(){var e,t,r={statusText:l.statusText,headers:(e=l.getAllResponseHeaders()||"",t=new f,e.replace(/\r?\n[\t ]+/g," ").split("\r").map((function(e){return 0===e.indexOf("\n")?e.substr(1,e.length):e})).forEach((function(e){var r=e.split(":"),o=r.shift().trim();if(o){var n=r.join(":").trim();try{t.append(o,n)}catch(e){console.warn("Response "+e.message)}}})),t)};0===u.url.indexOf("file://")&&(l.status<200||l.status>599)?r.status=200:r.status=l.status,r.url="responseURL"in l?l.responseURL:r.headers.get("X-Request-URL");var o="response"in l?l.response:l.responseText;setTimeout((function(){n(new m(o,r))}),0)},l.onerror=function(){setTimeout((function(){i(new TypeError("Network request failed"))}),0)},l.ontimeout=function(){setTimeout((function(){i(new TypeError("Network request timed out"))}),0)},l.onabort=function(){setTimeout((function(){i(new g("Aborted","AbortError"))}),0)},l.open(u.method,function(e){try{return""===e&&t.location.href?t.location.href:e}catch(t){return e}}(u.url),!0),"include"===u.credentials?l.withCredentials=!0:"omit"===u.credentials&&(l.withCredentials=!1),"responseType"in l&&(o.blob?l.responseType="blob":o.arrayBuffer&&(l.responseType="arraybuffer")),"redirect"in l&&(l.redirect=u.redirect),r&&"object"==typeof r.headers&&!(r.headers instanceof f||t.Headers&&r.headers instanceof t.Headers)){var h=[];Object.getOwnPropertyNames(r.headers).forEach((function(e){h.push(a(e)),l.setRequestHeader(e,s(r.headers[e]))})),u.headers.forEach((function(e,t){-1===h.indexOf(t)&&l.setRequestHeader(t,e)}))}else u.headers.forEach((function(e,t){l.setRequestHeader(t,e)}));u.signal&&(u.signal.addEventListener("abort",c),l.onreadystatechange=function(){4===l.readyState&&u.signal.removeEventListener("abort",c)}),l.send(void 0===u._bodyInit?null:u._bodyInit)}))}A.polyfill=!0,t.fetch||(t.fetch=A,t.Headers=f,t.Request=b,t.Response=m)})()})(); \ No newline at end of file +(()=>{var e={847(e,t,r){var n,o;n=function(){"use strict";function e(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=Array(t);r=t.length?{done:!0}:{done:!1,value:t[o++]}},e:function(e){throw e},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var a,s=!0,l=!1;return{s:function(){n=n.call(t)},n:function(){var e=n.next();return s=e.done,e},e:function(e){l=!0,a=e},f:function(){try{s||null==n.return||n.return()}finally{if(l)throw a}}}}function s(){return s="undefined"!=typeof Reflect&&Reflect.get?Reflect.get.bind():function(e,t,r){var n=function(e,t){for(;!{}.hasOwnProperty.call(e,t)&&null!==(e=l(e)););return e}(e,t);if(n){var o=Object.getOwnPropertyDescriptor(n,t);return o.get?o.get.call(arguments.length<3?e:r):o.value}},s.apply(null,arguments)}function l(e){return l=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(e){return e.__proto__||Object.getPrototypeOf(e)},l(e)}function c(){try{var e=!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],function(){}))}catch(e){}return(c=function(){return!!e})()}function u(e,t){return u=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},u(e,t)}function f(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var n=r.call(e,t||"default");if("object"!=typeof n)return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}!function(e){e.AbortSignal,e.AbortController}("undefined"!=typeof self?self:r.g);var p=function(){return i(function e(){n(this,e),Object.defineProperty(this,"listeners",{value:{},writable:!0,configurable:!0})},[{key:"addEventListener",value:function(e,t,r){e in this.listeners||(this.listeners[e]=[]),this.listeners[e].push({callback:t,options:r})}},{key:"removeEventListener",value:function(e,t){if(e in this.listeners)for(var r=this.listeners[e],n=0,o=r.length;n>1)+7),s=new n(a>>3<<3);t=55296&&l<=56319){if(t=55296&&l<=56319)continue}if(o+4>s.length){a+=8,a=(a*=1+t/e.length*2)>>3<<3;var u=new Uint8Array(a);u.set(s),s=u}if(4294967168&l){if(4294965248&l)if(4294901760&l){if(4292870144&l)continue;s[o++]=l>>18&7|240,s[o++]=l>>12&63|128,s[o++]=l>>6&63|128}else s[o++]=l>>12&15|224,s[o++]=l>>6&63|128;else s[o++]=l>>6&31|192;s[o++]=63&l|128}else s[o++]=l}return s.slice(0,o)},y="function"==typeof TextDecoder?TextDecoder.prototype.decode.bind(new TextDecoder):function(e){for(var t=e.length,r=[],n=0;n239?4:l>223?3:l>191?2:1;if(n+u<=t)switch(u){case 1:l<128&&(c=l);break;case 2:128==(192&(o=e[n+1]))&&(s=(31&l)<<6|63&o)>127&&(c=s);break;case 3:o=e[n+1],i=e[n+2],128==(192&o)&&128==(192&i)&&(s=(15&l)<<12|(63&o)<<6|63&i)>2047&&(s<55296||s>57343)&&(c=s);break;case 4:o=e[n+1],i=e[n+2],a=e[n+3],128==(192&o)&&128==(192&i)&&128==(192&a)&&(s=(15&l)<<18|(63&o)<<12|(63&i)<<6|63&a)>65535&&s<1114112&&(c=s)}null===c?(c=65533,u=1):c>65535&&(c-=65536,r.push(c>>>10&1023|55296),c=56320|1023&c),r.push(c),n+=u}for(var f=r.length,p="",d=0;d>2,u=(3&o)<<4|a>>4,f=(15&a)<<2|l>>6,p=63&l;s||(p=64,i||(f=64)),r.push(t[c],t[u],t[f],t[p])}return r.join("")}var s=Object.create||function(e){function t(){}return t.prototype=e,new t};function l(e){return Object.prototype.toString.call(e).slice(8,-1)}function c(e,t){return"object"==typeof e&&Object.prototype.isPrototypeOf.call(e.prototype,t)}function u(e){return"DataView"===l(e)||c(i.DataView,e)}var f=["Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Uint16Array","Int32Array","Uint32Array","Float32Array","Float64Array","ArrayBuffer"];function p(e){return t=f,r=l(e),-1!==t.indexOf(r)||c(i.ArrayBuffer,e);var t,r}function d(e,r){r=null==r?{}:r;for(var n=0,o=(e=e?e.slice():[]).length;n=t.size&&r.close()})}})}}catch(e){try{new ReadableStream({}),g=function(e){var t=0;return new ReadableStream({pull:function(r){return e.slice(t,t+524288).arrayBuffer().then(function(n){t+=n.byteLength;var o=new Uint8Array(n);r.enqueue(o),t==e.size&&r.close()})}})}}catch(e){try{new Response("").body.getReader().read(),g=function(){return new Response(this).body}}catch(e){g=function(){throw new Error("Include https://github.com/MattiasBuelens/web-streams-polyfill")}}}}function w(e){return new Promise(function(t,r){e.onload=e.onerror=function(n){e.onload=e.onerror=null,"load"===n.type?t(e.result||e):r(new Error("Failed to read the blob/file"))}})}v.arrayBuffer||(v.arrayBuffer=function(){var e=new FileReader;return e.readAsArrayBuffer(this),w(e)}),v.text||(v.text=function(){var e=new FileReader;return e.readAsText(this),w(e)}),v.stream||(v.stream=g)},void 0===(o=n.apply(t,[t]))||(e.exports=o)},767(e,t,r){"use strict";!function(e){function t(){}function r(){}var n=String.fromCharCode,o={}.toString,i=o.call(e.SharedArrayBuffer),a=o(),s=e.Uint8Array,l=s||Array,c=s?ArrayBuffer:l,u=c.isView||function(e){return e&&"length"in e},f=o.call(c.prototype);c=r.prototype;var p=e.TextEncoder,d=new(s?Uint16Array:l)(32);t.prototype.decode=function(e){if(!u(e)){var t=o.call(e);if(t!==f&&t!==i&&t!==a)throw TypeError("Failed to execute 'decode' on 'TextDecoder': The provided value is not of type '(ArrayBuffer or ArrayBufferView)'");e=s?new l(e):e||[]}for(var r,c,p,h=t="",y=0,b=0|e.length,g=b-32|0,v=0,w=0,m=0,E=-1;y>4){case 15:if(2!=(p=255&e[y=y+1|0])>>6||247>6?w+4|0:24,c=c+256&768;case 13:case 12:v<<=6,v|=(31&c)<<6|63&(p=255&e[y=y+1|0]),w=w+7|0,y>6&&v>>w&&1114112>v?(c=v,0<=(v=v-65536|0)&&(E=55296+(v>>10)|0,c=56320+(1023&v)|0,31>m?(d[m]=E,m=m+1|0,E=-1):(p=E,E=c,c=p))):(y=y-(c>>=8)-1|0,c=65533),v=w=0,r=y<=g?32:b-y|0;default:d[m]=c;continue;case 11:case 10:case 9:case 8:}d[m]=65533}if(h+=n(d[0],d[1],d[2],d[3],d[4],d[5],d[6],d[7],d[8],d[9],d[10],d[11],d[12],d[13],d[14],d[15],d[16],d[17],d[18],d[19],d[20],d[21],d[22],d[23],d[24],d[25],d[26],d[27],d[28],d[29],d[30],d[31]),32>m&&(h=h.slice(0,m-32|0)),y>>31,E=-1,h.length=a)n[o]=a;else{if(2047>=a)n[o]=192|a>>6;else{e:{if(55296<=a)if(56319>=a){var c=0|e.charCodeAt(t=t+1|0);if(56320<=c&&57343>=c){if(65535<(a=(a<<10)+c-56613888|0)){n[o]=240|a>>18,n[o=o+1|0]=128|a>>12&63,n[o=o+1|0]=128|a>>6&63,n[o=o+1|0]=128|63&a;continue}break e}a=65533}else 57343>=a&&(a=65533);!i&&t<<1>12,n[o=o+1|0]=128|a>>6&63}n[o=o+1|0]=128|63&a}}return s?n.subarray(0,o):n.slice(0,o)},p||(e.TextDecoder=t,e.TextEncoder=r)}(""+void 0==typeof r.g?""+void 0==typeof self?this:self:r.g)}},t={};function r(n){var o=t[n];if(void 0!==o)return o.exports;var i=t[n]={exports:{}};return e[n].call(i.exports,i,i.exports,r),i.exports}r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),(()=>{"use strict";function e(e,r,...n){if(!e)throw new TypeError(t(r,n))}function t(e,t){let r=0;return e.replace(/%[os]/gu,()=>n(t[r++]))}function n(e){return"object"!=typeof e||null===e?String(e):Object.prototype.toString.call(e)}const o="undefined"!=typeof window?window:"undefined"!=typeof self?self:void 0!==r.g?r.g:"undefined"!=typeof globalThis?globalThis:void 0;let i;class a{constructor(e,t){this.code=e,this.message=t}warn(...e){var t;try{i;const r=(null!==(t=(new Error).stack)&&void 0!==t?t:"").replace(/^(?:.+?\n){2}/gu,"\n");console.warn(this.message,...e,r)}catch(e){}}}const s=new a("W01","Unable to initialize event under dispatching."),l=new a("W02","Assigning any falsy value to 'cancelBubble' property has no effect."),c=new a("W03","Assigning any truthy value to 'returnValue' property has no effect."),u=new a("W04","Unable to preventDefault on non-cancelable events."),f=new a("W05","Unable to preventDefault inside passive event listener invocation."),p=new a("W06","An event listener wasn't added because it has been added already: %o, %o"),d=new a("W07","The %o option value was abandoned because the event listener wasn't added as duplicated."),h=new a("W08","The 'callback' argument must be a function or an object that has 'handleEvent' method: %o");new a("W09","Event attribute handler must be a function: %o");class y{static get NONE(){return b}static get CAPTURING_PHASE(){return g}static get AT_TARGET(){return v}static get BUBBLING_PHASE(){return w}constructor(e,t){Object.defineProperty(this,"isTrusted",{value:!1,enumerable:!0});const r=null!=t?t:{};m.set(this,{type:String(e),bubbles:Boolean(r.bubbles),cancelable:Boolean(r.cancelable),composed:Boolean(r.composed),target:null,currentTarget:null,stopPropagationFlag:!1,stopImmediatePropagationFlag:!1,canceledFlag:!1,inPassiveListenerFlag:!1,dispatchFlag:!1,timeStamp:Date.now()})}get type(){return E(this).type}get target(){return E(this).target}get srcElement(){return E(this).target}get currentTarget(){return E(this).currentTarget}composedPath(){const e=E(this).currentTarget;return e?[e]:[]}get NONE(){return b}get CAPTURING_PHASE(){return g}get AT_TARGET(){return v}get BUBBLING_PHASE(){return w}get eventPhase(){return E(this).dispatchFlag?2:0}stopPropagation(){E(this).stopPropagationFlag=!0}get cancelBubble(){return E(this).stopPropagationFlag}set cancelBubble(e){e?E(this).stopPropagationFlag=!0:l.warn()}stopImmediatePropagation(){const e=E(this);e.stopPropagationFlag=e.stopImmediatePropagationFlag=!0}get bubbles(){return E(this).bubbles}get cancelable(){return E(this).cancelable}get returnValue(){return!E(this).canceledFlag}set returnValue(e){e?c.warn():A(E(this))}preventDefault(){A(E(this))}get defaultPrevented(){return E(this).canceledFlag}get composed(){return E(this).composed}get isTrusted(){return!1}get timeStamp(){return E(this).timeStamp}initEvent(e,t=!1,r=!1){const n=E(this);n.dispatchFlag?s.warn():m.set(this,{...n,type:String(e),bubbles:Boolean(t),cancelable:Boolean(r),target:null,currentTarget:null,stopPropagationFlag:!1,stopImmediatePropagationFlag:!1,canceledFlag:!1})}}const b=0,g=1,v=2,w=3,m=new WeakMap;function E(t,r="this"){const n=m.get(t);return e(null!=n,"'%s' must be an object that Event constructor created, but got another one: %o",r,t),n}function A(e){e.inPassiveListenerFlag?f.warn():e.cancelable?e.canceledFlag=!0:u.warn()}Object.defineProperty(y,"NONE",{enumerable:!0}),Object.defineProperty(y,"CAPTURING_PHASE",{enumerable:!0}),Object.defineProperty(y,"AT_TARGET",{enumerable:!0}),Object.defineProperty(y,"BUBBLING_PHASE",{enumerable:!0});const T=Object.getOwnPropertyNames(y.prototype);for(let e=0;eo,configurable:!0,enumerable:!0})}}class B extends y{static wrap(e){return new(L(e))(e)}constructor(e){super(e.type,{bubbles:e.bubbles,cancelable:e.cancelable,composed:e.composed}),e.cancelBubble&&super.stopPropagation(),e.defaultPrevented&&super.preventDefault(),P.set(this,{original:e});const t=Object.keys(e);for(let r=0;rr!==t),!1):(e.listeners.splice(t,1),!0)}j.set(Object.prototype,B),void 0!==o&&void 0!==o.Event&&j.set(o.Event.prototype,B);class H{constructor(){V.set(this,Object.create(null))}addEventListener(e,t,r){const n=q(this),{callback:o,capture:i,once:a,passive:s,signal:l,type:c}=function(e,t,r){var n;return G(t),"object"==typeof r&&null!==r?{type:String(e),callback:null!=t?t:void 0,capture:Boolean(r.capture),passive:Boolean(r.passive),once:Boolean(r.once),signal:null!==(n=r.signal)&&void 0!==n?n:void 0}:{type:String(e),callback:null!=t?t:void 0,capture:Boolean(r),passive:!1,once:!1,signal:void 0}}(e,t,r);if(null==o||(null==l?void 0:l.aborted))return;const u=function(e,t){var r;return null!==(r=e[t])&&void 0!==r?r:e[t]={attrCallback:void 0,attrListener:void 0,cow:!1,listeners:[]}}(n,c),f=C(u,o,i);-1===f?function(e,t,r,n,o,i){let a;i&&(a=N.bind(null,e,t,r),i.addEventListener("abort",a));const s=function(e,t,r,n,o,i){return{callback:e,flags:(t?1:0)|(r?2:0)|(n?4:0),signal:o,signalListener:i}}(t,r,n,o,i,a);e.cow?(e.cow=!1,e.listeners=[...e.listeners,s]):e.listeners.push(s)}(u,o,i,s,a,l):function(e,t,r,n){p.warn(D(e)?"capture":"bubble",e.callback),I(e)!==t&&d.warn("passive"),U(e)!==r&&d.warn("once"),e.signal!==n&&d.warn("signal")}(u.listeners[f],s,a,l)}removeEventListener(e,t,r){const n=q(this),{callback:o,capture:i,type:a}=function(e,t,r){return G(t),"object"==typeof r&&null!==r?{type:String(e),callback:null!=t?t:void 0,capture:Boolean(r.capture)}:{type:String(e),callback:null!=t?t:void 0,capture:Boolean(r)}}(e,t,r),s=n[a];null!=o&&s&&N(s,o,i)}dispatchEvent(e){const t=q(this)[String(e.type)];if(null==t)return!0;const r=e instanceof y?e:B.wrap(e),n=E(r,"event");if(n.dispatchFlag)throw i="This event has been in dispatching.",o.DOMException?new o.DOMException(i,"InvalidStateError"):(null==R&&(R=class e extends Error{constructor(t){super(t),Error.captureStackTrace&&Error.captureStackTrace(this,e)}get code(){return 11}get name(){return"InvalidStateError"}},Object.defineProperties(R.prototype,{code:{enumerable:!0},name:{enumerable:!0}}),_(R),_(R.prototype)),new R(i));var i;if(n.dispatchFlag=!0,n.target=n.currentTarget=this,!n.stopPropagationFlag){const{cow:e,listeners:o}=t;t.cow=!0;for(let i=0;i(e^this.getRandomValues(new Uint8Array(1))[0]&15>>e/4).toString(16))}},r(767);var z="undefined"!=typeof globalThis&&globalThis||"undefined"!=typeof self&&self||void 0!==r.g&&r.g||{},Y="URLSearchParams"in z,Z="Symbol"in z&&"iterator"in Symbol,K="FileReader"in z&&"Blob"in z&&function(){try{return new Blob,!0}catch(e){return!1}}(),Q="FormData"in z,$="ArrayBuffer"in z;if($)var J=["[object Int8Array]","[object Uint8Array]","[object Uint8ClampedArray]","[object Int16Array]","[object Uint16Array]","[object Int32Array]","[object Uint32Array]","[object Float32Array]","[object Float64Array]"],ee=ArrayBuffer.isView||function(e){return e&&J.indexOf(Object.prototype.toString.call(e))>-1};function te(e){if("string"!=typeof e&&(e=String(e)),/[^a-z0-9\-#$%&'*+.^_`|~!]/i.test(e)||""===e)throw new TypeError('Invalid character in header field name: "'+e+'"');return e.toLowerCase()}function re(e){return"string"!=typeof e&&(e=String(e)),e}function ne(e){var t={next:function(){var t=e.shift();return{done:void 0===t,value:t}}};return Z&&(t[Symbol.iterator]=function(){return t}),t}function oe(e){this.map={},e instanceof oe?e.forEach(function(e,t){this.append(t,e)},this):Array.isArray(e)?e.forEach(function(e){if(2!=e.length)throw new TypeError("Headers constructor: expected name/value pair to be length 2, found"+e.length);this.append(e[0],e[1])},this):e&&Object.getOwnPropertyNames(e).forEach(function(t){this.append(t,e[t])},this)}function ie(e){if(!e._noBody)return e.bodyUsed?Promise.reject(new TypeError("Already read")):void(e.bodyUsed=!0)}function ae(e){return new Promise(function(t,r){e.onload=function(){t(e.result)},e.onerror=function(){r(e.error)}})}function se(e){var t=new FileReader,r=ae(t);return t.readAsArrayBuffer(e),r}function le(e){if(e.slice)return e.slice(0);var t=new Uint8Array(e.byteLength);return t.set(new Uint8Array(e)),t.buffer}function ce(){return this.bodyUsed=!1,this._initBody=function(e){var t;this.bodyUsed=this.bodyUsed,this._bodyInit=e,e?"string"==typeof e?this._bodyText=e:K&&Blob.prototype.isPrototypeOf(e)?this._bodyBlob=e:Q&&FormData.prototype.isPrototypeOf(e)?this._bodyFormData=e:Y&&URLSearchParams.prototype.isPrototypeOf(e)?this._bodyText=e.toString():$&&K&&(t=e)&&DataView.prototype.isPrototypeOf(t)?(this._bodyArrayBuffer=le(e.buffer),this._bodyInit=new Blob([this._bodyArrayBuffer])):$&&(ArrayBuffer.prototype.isPrototypeOf(e)||ee(e))?this._bodyArrayBuffer=le(e):this._bodyText=e=Object.prototype.toString.call(e):(this._noBody=!0,this._bodyText=""),this.headers.get("content-type")||("string"==typeof e?this.headers.set("content-type","text/plain;charset=UTF-8"):this._bodyBlob&&this._bodyBlob.type?this.headers.set("content-type",this._bodyBlob.type):Y&&URLSearchParams.prototype.isPrototypeOf(e)&&this.headers.set("content-type","application/x-www-form-urlencoded;charset=UTF-8"))},K&&(this.blob=function(){var e=ie(this);if(e)return e;if(this._bodyBlob)return Promise.resolve(this._bodyBlob);if(this._bodyArrayBuffer)return Promise.resolve(new Blob([this._bodyArrayBuffer]));if(this._bodyFormData)throw new Error("could not read FormData body as blob");return Promise.resolve(new Blob([this._bodyText]))}),this.arrayBuffer=function(){if(this._bodyArrayBuffer)return ie(this)||(ArrayBuffer.isView(this._bodyArrayBuffer)?Promise.resolve(this._bodyArrayBuffer.buffer.slice(this._bodyArrayBuffer.byteOffset,this._bodyArrayBuffer.byteOffset+this._bodyArrayBuffer.byteLength)):Promise.resolve(this._bodyArrayBuffer));if(K)return this.blob().then(se);throw new Error("could not read as ArrayBuffer")},this.text=function(){var e,t,r,n,o,i=ie(this);if(i)return i;if(this._bodyBlob)return e=this._bodyBlob,r=ae(t=new FileReader),o=(n=/charset=([A-Za-z0-9_-]+)/.exec(e.type))?n[1]:"utf-8",t.readAsText(e,o),r;if(this._bodyArrayBuffer)return Promise.resolve(function(e){for(var t=new Uint8Array(e),r=new Array(t.length),n=0;n-1?n:r),this.mode=t.mode||this.mode||null,this.signal=t.signal||this.signal||function(){if("AbortController"in z)return(new AbortController).signal}(),this.referrer=null,this.redirect=t.redirect||"follow",("GET"===this.method||"HEAD"===this.method)&&o)throw new TypeError("Body not allowed for GET or HEAD requests");if(this._initBody(o),!("GET"!==this.method&&"HEAD"!==this.method||"no-store"!==t.cache&&"no-cache"!==t.cache)){var i=/([?&])_=[^&]*/;i.test(this.url)?this.url=this.url.replace(i,"$1_="+(new Date).getTime()):this.url+=(/\?/.test(this.url)?"&":"?")+"_="+(new Date).getTime()}}function pe(e){var t=new FormData;return e.trim().split("&").forEach(function(e){if(e){var r=e.split("="),n=r.shift().replace(/\+/g," "),o=r.join("=").replace(/\+/g," ");t.append(decodeURIComponent(n),decodeURIComponent(o))}}),t}function de(e,t){if(!(this instanceof de))throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.');if(t||(t={}),this.type="default",this.status=void 0===t.status?200:t.status,this.status<200||this.status>599)throw new RangeError("Failed to construct 'Response': The status provided (0) is outside the range [200, 599].");this.ok=this.status>=200&&this.status<300,this.statusText=void 0===t.statusText?"":""+t.statusText,this.headers=new oe(t.headers),this.url=t.url||"",this._initBody(e)}fe.prototype.clone=function(){return new fe(this,{body:this._bodyInit})},ce.call(fe.prototype),ce.call(de.prototype),de.prototype.clone=function(){return new de(this._bodyInit,{status:this.status,statusText:this.statusText,headers:new oe(this.headers),url:this.url})},de.error=function(){var e=new de(null,{status:200,statusText:""});return e.ok=!1,e.status=0,e.type="error",e};var he=[301,302,303,307,308];de.redirect=function(e,t){if(-1===he.indexOf(t))throw new RangeError("Invalid status code");return new de(null,{status:t,headers:{location:e}})};var ye=z.DOMException;try{new ye}catch(e){(ye=function(e,t){this.message=e,this.name=t;var r=Error(e);this.stack=r.stack}).prototype=Object.create(Error.prototype),ye.prototype.constructor=ye}function be(e,t){return new Promise(function(r,n){var o=new fe(e,t);if(o.signal&&o.signal.aborted)return n(new ye("Aborted","AbortError"));var i=new XMLHttpRequest;function a(){i.abort()}if(i.onload=function(){var e,t,n={statusText:i.statusText,headers:(e=i.getAllResponseHeaders()||"",t=new oe,e.replace(/\r?\n[\t ]+/g," ").split("\r").map(function(e){return 0===e.indexOf("\n")?e.substr(1,e.length):e}).forEach(function(e){var r=e.split(":"),n=r.shift().trim();if(n){var o=r.join(":").trim();try{t.append(n,o)}catch(e){console.warn("Response "+e.message)}}}),t)};0===o.url.indexOf("file://")&&(i.status<200||i.status>599)?n.status=200:n.status=i.status,n.url="responseURL"in i?i.responseURL:n.headers.get("X-Request-URL");var a="response"in i?i.response:i.responseText;setTimeout(function(){r(new de(a,n))},0)},i.onerror=function(){setTimeout(function(){n(new TypeError("Network request failed"))},0)},i.ontimeout=function(){setTimeout(function(){n(new TypeError("Network request timed out"))},0)},i.onabort=function(){setTimeout(function(){n(new ye("Aborted","AbortError"))},0)},i.open(o.method,function(e){try{return""===e&&z.location.href?z.location.href:e}catch(t){return e}}(o.url),!0),"include"===o.credentials?i.withCredentials=!0:"omit"===o.credentials&&(i.withCredentials=!1),"responseType"in i&&(K?i.responseType="blob":$&&(i.responseType="arraybuffer")),"redirect"in i&&(i.redirect=o.redirect),t&&"object"==typeof t.headers&&!(t.headers instanceof oe||z.Headers&&t.headers instanceof z.Headers)){var s=[];Object.getOwnPropertyNames(t.headers).forEach(function(e){s.push(te(e)),i.setRequestHeader(e,re(t.headers[e]))}),o.headers.forEach(function(e,t){-1===s.indexOf(t)&&i.setRequestHeader(t,e)})}else o.headers.forEach(function(e,t){i.setRequestHeader(t,e)});o.signal&&(o.signal.addEventListener("abort",a),i.onreadystatechange=function(){4===i.readyState&&o.signal.removeEventListener("abort",a)}),i.send(void 0===o._bodyInit?null:o._bodyInit)})}be.polyfill=!0,z.fetch||(z.fetch=be,z.Headers=oe,z.Request=fe,z.Response=de)})()})(); \ No newline at end of file diff --git a/pkg/download/engine/polyfill/package-lock.json b/pkg/download/engine/polyfill/package-lock.json index e594889d..a1ebe8d8 100644 --- a/pkg/download/engine/polyfill/package-lock.json +++ b/pkg/download/engine/polyfill/package-lock.json @@ -1,7 +1,7 @@ { "name": "polyfill", "version": "1.0.0", - "lockfileVersion": 2, + "lockfileVersion": 3, "requires": true, "packages": { "": { @@ -10,7 +10,9 @@ "hasInstallScript": true, "license": "ISC", "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" }, @@ -22,260 +24,277 @@ }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", - "resolved": "https://registry.npmmirror.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10.0.0" } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/source-map": { - "version": "0.3.3", - "resolved": "https://registry.npmmirror.com/@jridgewell/source-map/-/source-map-0.3.3.tgz", - "integrity": "sha512-b+fsZXeLYi9fEULmfBrhxn4IrPlINf8fiNarzTof004v3lFdntdwa9PF7vFJqm3mg7s+ScJMxXaE3Acp1irZcg==", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.18", - "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", - "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@types/eslint": { - "version": "8.37.0", - "resolved": "https://registry.npmmirror.com/@types/eslint/-/eslint-8.37.0.tgz", - "integrity": "sha512-Piet7dG2JBuDIfohBngQ3rCt7MgO9xCO4xIMKxBThCq5PNRB91IjlJ10eJVwfoNtvTErmxLzwBZ7rHZtbOMmFQ==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "node_modules/@types/eslint-scope": { - "version": "3.7.4", - "resolved": "https://registry.npmmirror.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", - "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "dev": true, + "license": "MIT", "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "node_modules/@types/estree": { - "version": "0.0.51", - "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-0.0.51.tgz", - "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==", - "dev": true + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" }, "node_modules/@types/json-schema": { - "version": "7.0.11", - "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.11.tgz", - "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", - "dev": true + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" }, "node_modules/@types/node": { - "version": "18.15.11", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-18.15.11.tgz", - "integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==", - "dev": true + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } }, "node_modules/@webassemblyjs/ast": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/ast/-/ast-1.11.1.tgz", - "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/helper-numbers": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1" + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", - "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==", - "dev": true + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", - "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==", - "dev": true + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", - "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==", - "dev": true + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", - "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.11.1", - "@webassemblyjs/helper-api-error": "1.11.1", + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", - "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==", - "dev": true + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", - "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" } }, "node_modules/@webassemblyjs/ieee754": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", - "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "dev": true, + "license": "MIT", "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "node_modules/@webassemblyjs/leb128": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", - "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@xtuc/long": "4.2.2" } }, "node_modules/@webassemblyjs/utf8": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", - "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==", - "dev": true + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", - "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/helper-wasm-section": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1", - "@webassemblyjs/wasm-opts": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "@webassemblyjs/wast-printer": "1.11.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" } }, "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", - "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/ieee754": "1.11.1", - "@webassemblyjs/leb128": "1.11.1", - "@webassemblyjs/utf8": "1.11.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, - "node_modules/@webassemblyjs/wasm-opts": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-opts/-/wasm-opts-1.11.1.tgz", - "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" } }, "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", - "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-api-error": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/ieee754": "1.11.1", - "@webassemblyjs/leb128": "1.11.1", - "@webassemblyjs/utf8": "1.11.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, "node_modules/@webassemblyjs/wast-printer": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", - "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "dev": true, + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "node_modules/@webpack-cli/configtest": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/@webpack-cli/configtest/-/configtest-2.0.1.tgz", - "integrity": "sha512-njsdJXJSiS2iNbQVS0eT8A/KPnmyH4pv1APj2K0d1wrZcBLw+yppxOy4CGqa0OxDJkzfL/XELDhD8rocnIwB5A==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", + "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.15.0" }, @@ -285,10 +304,11 @@ } }, "node_modules/@webpack-cli/info": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/@webpack-cli/info/-/info-2.0.1.tgz", - "integrity": "sha512-fE1UEWTwsAxRhrJNikE7v4EotYflkEhBL7EbajfkPlf6E37/2QshOy/D48Mw8G5XMFlQtS6YV42vtbG9zBpIQA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", + "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.15.0" }, @@ -298,10 +318,11 @@ } }, "node_modules/@webpack-cli/serve": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/@webpack-cli/serve/-/serve-2.0.1.tgz", - "integrity": "sha512-0G7tNyS+yW8TdgHwZKlDWYXFA6OJQnoLCQvYKkQP0Q2X205PSQ6RNUj0M+1OB/9gRQaUZ/ccYfaxd0nhaWKfjw==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", + "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.15.0" }, @@ -317,27 +338,38 @@ }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@xtuc/long/-/long-4.2.2.tgz", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", - "dev": true + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/abortcontroller-polyfill": { + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.8.tgz", + "integrity": "sha512-9f1iZ2uWh92VcrU9Y8x+LdM4DLj75VE0MJB8zuF1iUnroEptStw+DQ8EQPMUdfe5k+PkB1uUfDQfWbhstH8LrQ==", + "license": "MIT" }, "node_modules/acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, + "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -345,41 +377,74 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-assertions": { - "version": "1.8.0", - "resolved": "https://registry.npmmirror.com/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", - "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, "peerDependencies": { - "acorn": "^8" + "acorn": "^8.14.0" } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmmirror.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, "peerDependencies": { - "ajv": "^6.9.1" + "ajv": "^8.8.2" } }, "node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -390,41 +455,31 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "node_modules/baseline-browser-mapping": { + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", + "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, "engines": { - "node": ">= 4.0.0" + "node": ">=6.0.0" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, "node_modules/blob-polyfill": { "version": "7.0.20220408", - "resolved": "https://registry.npmmirror.com/blob-polyfill/-/blob-polyfill-7.0.20220408.tgz", - "integrity": "sha512-oD8Ydw+5lNoqq+en24iuPt1QixdPpe/nUF8azTHnviCZYu9zUC+TwdzIp5orpblJosNlgNbVmmAb//c6d6ImUQ==" - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } + "resolved": "https://registry.npmjs.org/blob-polyfill/-/blob-polyfill-7.0.20220408.tgz", + "integrity": "sha512-oD8Ydw+5lNoqq+en24iuPt1QixdPpe/nUF8azTHnviCZYu9zUC+TwdzIp5orpblJosNlgNbVmmAb//c6d6ImUQ==", + "license": "MIT" }, "node_modules/braces": { "version": "3.0.3", - "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, + "license": "MIT", "dependencies": { "fill-range": "^7.1.1" }, @@ -433,15 +488,32 @@ } }, "node_modules/browserslist": { - "version": "4.21.5", - "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.21.5.tgz", - "integrity": "sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, "dependencies": { - "caniuse-lite": "^1.0.30001449", - "electron-to-chromium": "^1.4.284", - "node-releases": "^2.0.8", - "update-browserslist-db": "^1.0.10" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -452,21 +524,53 @@ }, "node_modules/buffer-from": { "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -476,16 +580,32 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001478", - "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001478.tgz", - "integrity": "sha512-gMhDyXGItTHipJj2ApIvR+iVB5hd0KP3svMWWXDvZOmjzJJassGLMfxRkQCSYgGd2gtdL/ReeiyvMSFD1Ss6Mw==", - "dev": true + "version": "1.0.30001784", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", + "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" }, "node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -497,30 +617,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/chrome-trace-event": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", - "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.0" } }, "node_modules/ci-info": { "version": "3.9.0", - "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-3.9.0.tgz", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", "dev": true, "funding": [ @@ -529,15 +638,17 @@ "url": "https://github.com/sponsors/sibiraj-s" } ], + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/clone-deep": { "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/clone-deep/-/clone-deep-4.0.1.tgz", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", "dev": true, + "license": "MIT", "dependencies": { "is-plain-object": "^2.0.4", "kind-of": "^6.0.2", @@ -549,9 +660,10 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -561,33 +673,31 @@ }, "node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/colorette": { - "version": "2.0.19", - "resolved": "https://registry.npmmirror.com/colorette/-/colorette-2.0.19.tgz", - "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", - "dev": true + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" }, "node_modules/commander": { "version": "2.20.3", - "resolved": "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -599,9 +709,10 @@ }, "node_modules/define-data-property": { "version": "1.1.4", - "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, + "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -614,30 +725,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { - "version": "1.4.359", - "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.4.359.tgz", - "integrity": "sha512-OoVcngKCIuNXtZnsYoqlCvr0Cf3NIPzDIgwUfI9bdTFjXCrr79lI0kwQstLPZ7WhCezLlGksZk/BFAzoXC7GDw==", - "dev": true + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.12.0", - "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz", - "integrity": "sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==", + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" } }, "node_modules/envinfo": { - "version": "7.8.1", - "resolved": "https://registry.npmmirror.com/envinfo/-/envinfo-7.8.1.tgz", - "integrity": "sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", "dev": true, + "license": "MIT", "bin": { "envinfo": "dist/cli.js" }, @@ -646,46 +775,61 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.4" - }, + "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/es-errors": { "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/es-module-lexer": { - "version": "0.9.3", - "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-0.9.3.tgz", - "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", - "dev": true + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/eslint-scope": { "version": "5.1.1", - "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-5.1.1.tgz", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -696,9 +840,10 @@ }, "node_modules/esrecurse": { "version": "4.3.0", - "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -708,62 +853,92 @@ }, "node_modules/esrecurse/node_modules/estraverse": { "version": "5.3.0", - "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } }, "node_modules/estraverse": { "version": "4.3.0", - "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-4.3.0.tgz", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } }, + "node_modules/event-target-shim": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-6.0.2.tgz", + "integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, "node_modules/events": { "version": "3.3.0", - "resolved": "https://registry.npmmirror.com/events/-/events-3.3.0.tgz", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.x" } }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "dev": true, + "license": "MIT" }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, "node_modules/fastest-levenshtein": { "version": "1.0.16", - "resolved": "https://registry.npmmirror.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4.9.1" } }, "node_modules/fastestsmallesttextencoderdecoder": { "version": "1.0.22", - "resolved": "https://registry.npmmirror.com/fastestsmallesttextencoderdecoder/-/fastestsmallesttextencoderdecoder-1.0.22.tgz", - "integrity": "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==" + "resolved": "https://registry.npmjs.org/fastestsmallesttextencoderdecoder/-/fastestsmallesttextencoderdecoder-1.0.22.tgz", + "integrity": "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==", + "license": "CC0-1.0" }, "node_modules/fill-range": { "version": "7.1.1", - "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -773,9 +948,10 @@ }, "node_modules/find-up": { "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dev": true, + "license": "MIT", "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -786,54 +962,66 @@ }, "node_modules/find-yarn-workspace-root": { "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "micromatch": "^4.0.2" } }, - "node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", "dependencies": { - "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { - "node": ">=10" + "node": ">=12" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, "node_modules/function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -842,40 +1030,35 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 0.4" } }, "node_modules/glob-to-regexp": { "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true + "dev": true, + "license": "BSD-2-Clause" }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.3" + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -883,36 +1066,27 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", - "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } + "license": "ISC" }, "node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/has-property-descriptors": { "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, + "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" }, @@ -920,23 +1094,12 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -946,9 +1109,10 @@ }, "node_modules/hasown": { "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -957,10 +1121,11 @@ } }, "node_modules/import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", "dev": true, + "license": "MIT", "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" @@ -970,48 +1135,43 @@ }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, "node_modules/interpret": { "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/interpret/-/interpret-3.1.1.tgz", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=10.13.0" } }, "node_modules/is-core-module": { - "version": "2.12.0", - "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.12.0.tgz", - "integrity": "sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, + "license": "MIT", "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-docker": { "version": "2.2.1", - "resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-2.2.1.tgz", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", "dev": true, + "license": "MIT", "bin": { "is-docker": "cli.js" }, @@ -1024,18 +1184,20 @@ }, "node_modules/is-number": { "version": "7.0.0", - "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } }, "node_modules/is-plain-object": { "version": "2.0.4", - "resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-2.0.4.tgz", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", "dev": true, + "license": "MIT", "dependencies": { "isobject": "^3.0.1" }, @@ -1045,9 +1207,10 @@ }, "node_modules/is-wsl": { "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, + "license": "MIT", "dependencies": { "is-docker": "^2.0.0" }, @@ -1057,30 +1220,34 @@ }, "node_modules/isarray": { "version": "2.0.5", - "resolved": "https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/isobject": { "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/jest-worker": { "version": "27.5.1", - "resolved": "https://registry.npmmirror.com/jest-worker/-/jest-worker-27.5.1.tgz", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -1090,25 +1257,45 @@ "node": ">= 10.13.0" } }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", - "resolved": "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" }, "node_modules/json-stable-stringify": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/json-stable-stringify/-/json-stable-stringify-1.1.1.tgz", - "integrity": "sha512-SU/971Kt5qVQfJpyDveVhQ/vya+5hvrjClFOcr8c0Fq5aODJjMwutrOfCU+eCnVD5gpx1Q3fEqkyom77zH1iIg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "isarray": "^2.0.5", "jsonify": "^0.0.1", "object-keys": "^1.1.1" @@ -1121,10 +1308,11 @@ } }, "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, + "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, @@ -1134,45 +1322,54 @@ }, "node_modules/jsonify": { "version": "0.0.1", - "resolved": "https://registry.npmmirror.com/jsonify/-/jsonify-0.0.1.tgz", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", "dev": true, + "license": "Public Domain", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/kind-of": { "version": "6.0.3", - "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-6.0.3.tgz", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/klaw-sync": { "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/klaw-sync/-/klaw-sync-6.0.0.tgz", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.1.11" } }, "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmmirror.com/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/locate-path": { "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "dev": true, + "license": "MIT", "dependencies": { "p-locate": "^4.1.0" }, @@ -1180,17 +1377,29 @@ "node": ">=8" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge-stream": { "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -1201,18 +1410,20 @@ }, "node_modules/mime-db": { "version": "1.52.0", - "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { "version": "2.1.35", - "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, + "license": "MIT", "dependencies": { "mime-db": "1.52.0" }, @@ -1220,62 +1431,46 @@ "node": ">= 0.6" } }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/minimist": { "version": "1.2.8", - "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/neo-async": { "version": "2.6.2", - "resolved": "https://registry.npmmirror.com/neo-async/-/neo-async-2.6.2.tgz", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.10", - "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.10.tgz", - "integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==", - "dev": true + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" }, "node_modules/object-keys": { "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, "node_modules/open": { "version": "7.4.2", - "resolved": "https://registry.npmmirror.com/open/-/open-7.4.2.tgz", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", "dev": true, + "license": "MIT", "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" @@ -1287,32 +1482,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/p-limit": { "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, + "license": "MIT", "dependencies": { "p-try": "^2.0.0" }, "engines": { "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-locate": { "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, + "license": "MIT", "dependencies": { "p-limit": "^2.2.0" }, @@ -1322,33 +1513,34 @@ }, "node_modules/p-try": { "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/patch-package": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/patch-package/-/patch-package-8.0.0.tgz", - "integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", "dev": true, + "license": "MIT", "dependencies": { "@yarnpkg/lockfile": "^1.1.0", "chalk": "^4.1.2", "ci-info": "^3.7.0", "cross-spawn": "^7.0.3", "find-yarn-workspace-root": "^2.0.0", - "fs-extra": "^9.0.0", + "fs-extra": "^10.0.0", "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", "minimist": "^1.2.6", "open": "^7.4.2", - "rimraf": "^2.6.3", "semver": "^7.5.3", "slash": "^2.0.0", - "tmp": "^0.0.33", + "tmp": "^0.2.4", "yaml": "^2.2.2" }, "bin": { @@ -1361,48 +1553,44 @@ }, "node_modules/path-exists": { "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/path-parse": { "version": "1.0.7", - "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -1412,9 +1600,10 @@ }, "node_modules/pkg-dir": { "version": "4.2.0", - "resolved": "https://registry.npmmirror.com/pkg-dir/-/pkg-dir-4.2.0.tgz", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", "dev": true, + "license": "MIT", "dependencies": { "find-up": "^4.0.0" }, @@ -1422,29 +1611,12 @@ "node": ">=8" } }, - "node_modules/punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/rechoir": { "version": "0.8.0", - "resolved": "https://registry.npmmirror.com/rechoir/-/rechoir-0.8.0.tgz", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", "dev": true, + "license": "MIT", "dependencies": { "resolve": "^1.20.0" }, @@ -1452,25 +1624,43 @@ "node": ">= 10.13.0" } }, - "node_modules/resolve": { - "version": "1.22.2", - "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.2.tgz", - "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", "dependencies": { - "is-core-module": "^2.11.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/resolve-cwd": { "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", "dev": true, + "license": "MIT", "dependencies": { "resolve-from": "^5.0.0" }, @@ -1480,51 +1670,40 @@ }, "node_modules/resolve-from": { "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, "node_modules/schema-utils": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-3.1.1.tgz", - "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" }, "engines": { "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmmirror.com/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -1532,20 +1711,12 @@ "node": ">=10" } }, - "node_modules/serialize-javascript": { - "version": "6.0.1", - "resolved": "https://registry.npmmirror.com/serialize-javascript/-/serialize-javascript-6.0.1.tgz", - "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", - "dev": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/set-function-length": { "version": "1.2.2", - "resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, + "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -1560,9 +1731,10 @@ }, "node_modules/shallow-clone": { "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/shallow-clone/-/shallow-clone-3.0.1.tgz", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", "dev": true, + "license": "MIT", "dependencies": { "kind-of": "^6.0.2" }, @@ -1572,9 +1744,10 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, + "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -1584,79 +1757,94 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/slash": { "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/slash/-/slash-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/source-map": { "version": "0.6.1", - "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, "node_modules/source-map-support": { "version": "0.5.21", - "resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, + "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, "engines": { - "node": ">=10" + "node": ">=8" } }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/terser": { - "version": "5.16.9", - "resolved": "https://registry.npmmirror.com/terser/-/terser-5.16.9.tgz", - "integrity": "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg==", + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@jridgewell/source-map": "^0.3.2", - "acorn": "^8.5.0", + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -1668,20 +1856,24 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.7", - "resolved": "https://registry.npmmirror.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.7.tgz", - "integrity": "sha512-AfKwIktyP7Cu50xNjXF/6Qb5lBNzYaWpU6YfoX3uZicTx0zTy0stDDCsvjDapKsSDvOeWo5MEq4TmdBy2cNoHw==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.17", + "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.1", - "terser": "^5.16.5" + "schema-utils": "^4.3.0", + "terser": "^5.31.1" }, "engines": { "node": ">= 10.13.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, "peerDependencies": { "webpack": "^5.1.0" }, @@ -1698,22 +1890,21 @@ } }, "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, - "dependencies": { - "os-tmpdir": "~1.0.2" - }, + "license": "MIT", "engines": { - "node": ">=0.6.0" + "node": ">=14.14" } }, "node_modules/to-regex-range": { "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -1721,45 +1912,60 @@ "node": ">=8.0" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/universalify": { "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 10.0.0" } }, "node_modules/update-browserslist-db": { - "version": "1.0.10", - "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", - "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, "bin": { - "browserslist-lint": "cli.js" + "update-browserslist-db": "cli.js" }, "peerDependencies": { "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmmirror.com/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "dev": true, + "license": "MIT", "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -1769,35 +1975,38 @@ } }, "node_modules/webpack": { - "version": "5.78.0", - "resolved": "https://registry.npmmirror.com/webpack/-/webpack-5.78.0.tgz", - "integrity": "sha512-gT5DP72KInmE/3azEaQrISjTvLYlSM0j1Ezhht/KLVkrqtv10JoP/RXhwmX/frrutOPuSq3o5Vq0ehR/4Vmd1g==", + "version": "5.105.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", + "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^0.0.51", - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/wasm-edit": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "acorn": "^8.7.1", - "acorn-import-assertions": "^1.7.6", - "browserslist": "^4.14.5", + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.10.0", - "es-module-lexer": "^0.9.0", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", + "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^3.1.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.1.3", - "watchpack": "^2.4.0", - "webpack-sources": "^3.2.3" + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" @@ -1805,6 +2014,10 @@ "engines": { "node": ">=10.13.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, "peerDependenciesMeta": { "webpack-cli": { "optional": true @@ -1812,17 +2025,19 @@ } }, "node_modules/webpack-cli": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/webpack-cli/-/webpack-cli-5.0.1.tgz", - "integrity": "sha512-S3KVAyfwUqr0Mo/ur3NzIp6jnerNpo7GUO6so51mxLi1spqsA17YcMXy0WOIJtBSnj748lthxC6XLbNKh/ZC+A==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", + "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, + "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", - "@webpack-cli/configtest": "^2.0.1", - "@webpack-cli/info": "^2.0.1", - "@webpack-cli/serve": "^2.0.1", + "@webpack-cli/configtest": "^2.1.1", + "@webpack-cli/info": "^2.0.2", + "@webpack-cli/serve": "^2.0.5", "colorette": "^2.0.14", - "commander": "^9.4.1", + "commander": "^10.0.1", "cross-spawn": "^7.0.3", "envinfo": "^7.7.3", "fastest-levenshtein": "^1.0.12", @@ -1837,6 +2052,10 @@ "engines": { "node": ">=14.15.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, "peerDependencies": { "webpack": "5.x.x" }, @@ -1853,21 +2072,24 @@ } }, "node_modules/webpack-cli/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmmirror.com/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", "dev": true, + "license": "MIT", "engines": { - "node": "^12.20.0 || >=14" + "node": ">=14" } }, "node_modules/webpack-merge": { - "version": "5.8.0", - "resolved": "https://registry.npmmirror.com/webpack-merge/-/webpack-merge-5.8.0.tgz", - "integrity": "sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", "dev": true, + "license": "MIT", "dependencies": { "clone-deep": "^4.0.1", + "flat": "^5.0.2", "wildcard": "^2.0.0" }, "engines": { @@ -1875,24 +2097,27 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmmirror.com/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=10.13.0" } }, "node_modules/whatwg-fetch": { "version": "3.6.20", - "resolved": "https://registry.npmmirror.com/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", - "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -1904,1487 +2129,27 @@ } }, "node_modules/wildcard": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-2.0.0.tgz", - "integrity": "sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==", - "dev": true - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" }, "node_modules/yaml": { - "version": "2.5.0", - "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.5.0.tgz", - "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, + "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" - } - } - }, - "dependencies": { - "@discoveryjs/json-ext": { - "version": "0.5.7", - "resolved": "https://registry.npmmirror.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", - "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", - "dev": true - }, - "@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "dev": true, - "requires": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, - "@jridgewell/resolve-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "dev": true - }, - "@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "dev": true - }, - "@jridgewell/source-map": { - "version": "0.3.3", - "resolved": "https://registry.npmmirror.com/@jridgewell/source-map/-/source-map-0.3.3.tgz", - "integrity": "sha512-b+fsZXeLYi9fEULmfBrhxn4IrPlINf8fiNarzTof004v3lFdntdwa9PF7vFJqm3mg7s+ScJMxXaE3Acp1irZcg==", - "dev": true, - "requires": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, - "@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true - }, - "@jridgewell/trace-mapping": { - "version": "0.3.18", - "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", - "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", - "dev": true, - "requires": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" - } - }, - "@types/eslint": { - "version": "8.37.0", - "resolved": "https://registry.npmmirror.com/@types/eslint/-/eslint-8.37.0.tgz", - "integrity": "sha512-Piet7dG2JBuDIfohBngQ3rCt7MgO9xCO4xIMKxBThCq5PNRB91IjlJ10eJVwfoNtvTErmxLzwBZ7rHZtbOMmFQ==", - "dev": true, - "requires": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "@types/eslint-scope": { - "version": "3.7.4", - "resolved": "https://registry.npmmirror.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", - "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", - "dev": true, - "requires": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, - "@types/estree": { - "version": "0.0.51", - "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-0.0.51.tgz", - "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==", - "dev": true - }, - "@types/json-schema": { - "version": "7.0.11", - "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.11.tgz", - "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", - "dev": true - }, - "@types/node": { - "version": "18.15.11", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-18.15.11.tgz", - "integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==", - "dev": true - }, - "@webassemblyjs/ast": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/ast/-/ast-1.11.1.tgz", - "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", - "dev": true, - "requires": { - "@webassemblyjs/helper-numbers": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1" - } - }, - "@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", - "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==", - "dev": true - }, - "@webassemblyjs/helper-api-error": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", - "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==", - "dev": true - }, - "@webassemblyjs/helper-buffer": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", - "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==", - "dev": true - }, - "@webassemblyjs/helper-numbers": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", - "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", - "dev": true, - "requires": { - "@webassemblyjs/floating-point-hex-parser": "1.11.1", - "@webassemblyjs/helper-api-error": "1.11.1", - "@xtuc/long": "4.2.2" - } - }, - "@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", - "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==", - "dev": true - }, - "@webassemblyjs/helper-wasm-section": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", - "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1" - } - }, - "@webassemblyjs/ieee754": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", - "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", - "dev": true, - "requires": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "@webassemblyjs/leb128": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", - "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", - "dev": true, - "requires": { - "@xtuc/long": "4.2.2" - } - }, - "@webassemblyjs/utf8": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", - "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==", - "dev": true - }, - "@webassemblyjs/wasm-edit": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", - "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/helper-wasm-section": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1", - "@webassemblyjs/wasm-opts": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "@webassemblyjs/wast-printer": "1.11.1" - } - }, - "@webassemblyjs/wasm-gen": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", - "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/ieee754": "1.11.1", - "@webassemblyjs/leb128": "1.11.1", - "@webassemblyjs/utf8": "1.11.1" - } - }, - "@webassemblyjs/wasm-opts": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-opts/-/wasm-opts-1.11.1.tgz", - "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-buffer": "1.11.1", - "@webassemblyjs/wasm-gen": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1" - } - }, - "@webassemblyjs/wasm-parser": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", - "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/helper-api-error": "1.11.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.1", - "@webassemblyjs/ieee754": "1.11.1", - "@webassemblyjs/leb128": "1.11.1", - "@webassemblyjs/utf8": "1.11.1" - } - }, - "@webassemblyjs/wast-printer": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", - "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.11.1", - "@xtuc/long": "4.2.2" - } - }, - "@webpack-cli/configtest": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/@webpack-cli/configtest/-/configtest-2.0.1.tgz", - "integrity": "sha512-njsdJXJSiS2iNbQVS0eT8A/KPnmyH4pv1APj2K0d1wrZcBLw+yppxOy4CGqa0OxDJkzfL/XELDhD8rocnIwB5A==", - "dev": true, - "requires": {} - }, - "@webpack-cli/info": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/@webpack-cli/info/-/info-2.0.1.tgz", - "integrity": "sha512-fE1UEWTwsAxRhrJNikE7v4EotYflkEhBL7EbajfkPlf6E37/2QshOy/D48Mw8G5XMFlQtS6YV42vtbG9zBpIQA==", - "dev": true, - "requires": {} - }, - "@webpack-cli/serve": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/@webpack-cli/serve/-/serve-2.0.1.tgz", - "integrity": "sha512-0G7tNyS+yW8TdgHwZKlDWYXFA6OJQnoLCQvYKkQP0Q2X205PSQ6RNUj0M+1OB/9gRQaUZ/ccYfaxd0nhaWKfjw==", - "dev": true, - "requires": {} - }, - "@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true - }, - "@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true - }, - "@yarnpkg/lockfile": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", - "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", - "dev": true - }, - "acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", - "dev": true - }, - "acorn-import-assertions": { - "version": "1.8.0", - "resolved": "https://registry.npmmirror.com/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", - "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", - "dev": true, - "requires": {} - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmmirror.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "requires": {} - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "dev": true - }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "blob-polyfill": { - "version": "7.0.20220408", - "resolved": "https://registry.npmmirror.com/blob-polyfill/-/blob-polyfill-7.0.20220408.tgz", - "integrity": "sha512-oD8Ydw+5lNoqq+en24iuPt1QixdPpe/nUF8azTHnviCZYu9zUC+TwdzIp5orpblJosNlgNbVmmAb//c6d6ImUQ==" - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "braces": { - "version": "3.0.3", - "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "requires": { - "fill-range": "^7.1.1" - } - }, - "browserslist": { - "version": "4.21.5", - "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.21.5.tgz", - "integrity": "sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==", - "dev": true, - "requires": { - "caniuse-lite": "^1.0.30001449", - "electron-to-chromium": "^1.4.284", - "node-releases": "^2.0.8", - "update-browserslist-db": "^1.0.10" - } - }, - "buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true - }, - "call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dev": true, - "requires": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" - } - }, - "caniuse-lite": { - "version": "1.0.30001478", - "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001478.tgz", - "integrity": "sha512-gMhDyXGItTHipJj2ApIvR+iVB5hd0KP3svMWWXDvZOmjzJJassGLMfxRkQCSYgGd2gtdL/ReeiyvMSFD1Ss6Mw==", - "dev": true - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "node": ">= 14.6" }, - "dependencies": { - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } + "funding": { + "url": "https://github.com/sponsors/eemeli" } - }, - "chrome-trace-event": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", - "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", - "dev": true - }, - "ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true - }, - "clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "dev": true, - "requires": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "colorette": { - "version": "2.0.19", - "resolved": "https://registry.npmmirror.com/colorette/-/colorette-2.0.19.tgz", - "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", - "dev": true - }, - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - } - }, - "define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "requires": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - } - }, - "electron-to-chromium": { - "version": "1.4.359", - "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.4.359.tgz", - "integrity": "sha512-OoVcngKCIuNXtZnsYoqlCvr0Cf3NIPzDIgwUfI9bdTFjXCrr79lI0kwQstLPZ7WhCezLlGksZk/BFAzoXC7GDw==", - "dev": true - }, - "enhanced-resolve": { - "version": "5.12.0", - "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz", - "integrity": "sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==", - "dev": true, - "requires": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - } - }, - "envinfo": { - "version": "7.8.1", - "resolved": "https://registry.npmmirror.com/envinfo/-/envinfo-7.8.1.tgz", - "integrity": "sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw==", - "dev": true - }, - "es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dev": true, - "requires": { - "get-intrinsic": "^1.2.4" - } - }, - "es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true - }, - "es-module-lexer": { - "version": "0.9.3", - "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-0.9.3.tgz", - "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", - "dev": true - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true - }, - "eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - } - }, - "esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "requires": { - "estraverse": "^5.2.0" - }, - "dependencies": { - "estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true - } - } - }, - "estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true - }, - "events": { - "version": "3.3.0", - "resolved": "https://registry.npmmirror.com/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "fastest-levenshtein": { - "version": "1.0.16", - "resolved": "https://registry.npmmirror.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", - "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", - "dev": true - }, - "fastestsmallesttextencoderdecoder": { - "version": "1.0.22", - "resolved": "https://registry.npmmirror.com/fastestsmallesttextencoderdecoder/-/fastestsmallesttextencoderdecoder-1.0.22.tgz", - "integrity": "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==" - }, - "fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "requires": { - "to-regex-range": "^5.0.1" - } - }, - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "find-yarn-workspace-root": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", - "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", - "dev": true, - "requires": { - "micromatch": "^4.0.2" - } - }, - "fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "requires": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - } - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true - }, - "get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dev": true, - "requires": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" - } - }, - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true - }, - "gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, - "requires": { - "get-intrinsic": "^1.1.3" - } - }, - "graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "requires": { - "es-define-property": "^1.0.0" - } - }, - "has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "dev": true - }, - "has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true - }, - "hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "requires": { - "function-bind": "^1.1.2" - } - }, - "import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", - "dev": true, - "requires": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - } - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "interpret": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/interpret/-/interpret-3.1.1.tgz", - "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", - "dev": true - }, - "is-core-module": { - "version": "2.12.0", - "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.12.0.tgz", - "integrity": "sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==", - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true - }, - "is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true - }, - "is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "requires": { - "isobject": "^3.0.1" - } - }, - "is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "requires": { - "is-docker": "^2.0.0" - } - }, - "isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true - }, - "jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmmirror.com/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "requires": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - } - }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "json-stable-stringify": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/json-stable-stringify/-/json-stable-stringify-1.1.1.tgz", - "integrity": "sha512-SU/971Kt5qVQfJpyDveVhQ/vya+5hvrjClFOcr8c0Fq5aODJjMwutrOfCU+eCnVD5gpx1Q3fEqkyom77zH1iIg==", - "dev": true, - "requires": { - "call-bind": "^1.0.5", - "isarray": "^2.0.5", - "jsonify": "^0.0.1", - "object-keys": "^1.1.1" - } - }, - "jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.6", - "universalify": "^2.0.0" - } - }, - "jsonify": { - "version": "0.0.1", - "resolved": "https://registry.npmmirror.com/jsonify/-/jsonify-0.0.1.tgz", - "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", - "dev": true - }, - "kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true - }, - "klaw-sync": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/klaw-sync/-/klaw-sync-6.0.0.tgz", - "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.11" - } - }, - "loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmmirror.com/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", - "dev": true - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "requires": { - "p-locate": "^4.1.0" - } - }, - "merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", - "dev": true, - "requires": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - } - }, - "mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true - }, - "mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "requires": { - "mime-db": "1.52.0" - } - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true - }, - "neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmmirror.com/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true - }, - "node-releases": { - "version": "2.0.10", - "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.10.tgz", - "integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==", - "dev": true - }, - "object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "open": { - "version": "7.4.2", - "resolved": "https://registry.npmmirror.com/open/-/open-7.4.2.tgz", - "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", - "dev": true, - "requires": { - "is-docker": "^2.0.0", - "is-wsl": "^2.1.1" - } - }, - "os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "requires": { - "p-limit": "^2.2.0" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "patch-package": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/patch-package/-/patch-package-8.0.0.tgz", - "integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==", - "dev": true, - "requires": { - "@yarnpkg/lockfile": "^1.1.0", - "chalk": "^4.1.2", - "ci-info": "^3.7.0", - "cross-spawn": "^7.0.3", - "find-yarn-workspace-root": "^2.0.0", - "fs-extra": "^9.0.0", - "json-stable-stringify": "^1.0.2", - "klaw-sync": "^6.0.0", - "minimist": "^1.2.6", - "open": "^7.4.2", - "rimraf": "^2.6.3", - "semver": "^7.5.3", - "slash": "^2.0.0", - "tmp": "^0.0.33", - "yaml": "^2.2.2" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true - }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true - }, - "pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmmirror.com/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "requires": { - "find-up": "^4.0.0" - } - }, - "punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", - "dev": true - }, - "randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "requires": { - "safe-buffer": "^5.1.0" - } - }, - "rechoir": { - "version": "0.8.0", - "resolved": "https://registry.npmmirror.com/rechoir/-/rechoir-0.8.0.tgz", - "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", - "dev": true, - "requires": { - "resolve": "^1.20.0" - } - }, - "resolve": { - "version": "1.22.2", - "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.2.tgz", - "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", - "dev": true, - "requires": { - "is-core-module": "^2.11.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "requires": { - "resolve-from": "^5.0.0" - } - }, - "resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true - }, - "rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, - "schema-utils": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-3.1.1.tgz", - "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", - "dev": true, - "requires": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - } - }, - "semver": { - "version": "7.6.3", - "resolved": "https://registry.npmmirror.com/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true - }, - "serialize-javascript": { - "version": "6.0.1", - "resolved": "https://registry.npmmirror.com/serialize-javascript/-/serialize-javascript-6.0.1.tgz", - "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", - "dev": true, - "requires": { - "randombytes": "^2.1.0" - } - }, - "set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "requires": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - } - }, - "shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "dev": true, - "requires": { - "kind-of": "^6.0.2" - } - }, - "shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "requires": { - "shebang-regex": "^3.0.0" - } - }, - "shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true - }, - "slash": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/slash/-/slash-2.0.0.tgz", - "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", - "dev": true - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - }, - "source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true - }, - "tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "dev": true - }, - "terser": { - "version": "5.16.9", - "resolved": "https://registry.npmmirror.com/terser/-/terser-5.16.9.tgz", - "integrity": "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg==", - "dev": true, - "requires": { - "@jridgewell/source-map": "^0.3.2", - "acorn": "^8.5.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - } - }, - "terser-webpack-plugin": { - "version": "5.3.7", - "resolved": "https://registry.npmmirror.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.7.tgz", - "integrity": "sha512-AfKwIktyP7Cu50xNjXF/6Qb5lBNzYaWpU6YfoX3uZicTx0zTy0stDDCsvjDapKsSDvOeWo5MEq4TmdBy2cNoHw==", - "dev": true, - "requires": { - "@jridgewell/trace-mapping": "^0.3.17", - "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.1", - "terser": "^5.16.5" - } - }, - "tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "requires": { - "os-tmpdir": "~1.0.2" - } - }, - "to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "requires": { - "is-number": "^7.0.0" - } - }, - "universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true - }, - "update-browserslist-db": { - "version": "1.0.10", - "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", - "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==", - "dev": true, - "requires": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" - } - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmmirror.com/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", - "dev": true, - "requires": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - } - }, - "webpack": { - "version": "5.78.0", - "resolved": "https://registry.npmmirror.com/webpack/-/webpack-5.78.0.tgz", - "integrity": "sha512-gT5DP72KInmE/3azEaQrISjTvLYlSM0j1Ezhht/KLVkrqtv10JoP/RXhwmX/frrutOPuSq3o5Vq0ehR/4Vmd1g==", - "dev": true, - "requires": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^0.0.51", - "@webassemblyjs/ast": "1.11.1", - "@webassemblyjs/wasm-edit": "1.11.1", - "@webassemblyjs/wasm-parser": "1.11.1", - "acorn": "^8.7.1", - "acorn-import-assertions": "^1.7.6", - "browserslist": "^4.14.5", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.10.0", - "es-module-lexer": "^0.9.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.1.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.1.3", - "watchpack": "^2.4.0", - "webpack-sources": "^3.2.3" - } - }, - "webpack-cli": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/webpack-cli/-/webpack-cli-5.0.1.tgz", - "integrity": "sha512-S3KVAyfwUqr0Mo/ur3NzIp6jnerNpo7GUO6so51mxLi1spqsA17YcMXy0WOIJtBSnj748lthxC6XLbNKh/ZC+A==", - "dev": true, - "requires": { - "@discoveryjs/json-ext": "^0.5.0", - "@webpack-cli/configtest": "^2.0.1", - "@webpack-cli/info": "^2.0.1", - "@webpack-cli/serve": "^2.0.1", - "colorette": "^2.0.14", - "commander": "^9.4.1", - "cross-spawn": "^7.0.3", - "envinfo": "^7.7.3", - "fastest-levenshtein": "^1.0.12", - "import-local": "^3.0.2", - "interpret": "^3.1.1", - "rechoir": "^0.8.0", - "webpack-merge": "^5.7.3" - }, - "dependencies": { - "commander": { - "version": "9.5.0", - "resolved": "https://registry.npmmirror.com/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true - } - } - }, - "webpack-merge": { - "version": "5.8.0", - "resolved": "https://registry.npmmirror.com/webpack-merge/-/webpack-merge-5.8.0.tgz", - "integrity": "sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q==", - "dev": true, - "requires": { - "clone-deep": "^4.0.1", - "wildcard": "^2.0.0" - } - }, - "webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmmirror.com/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "dev": true - }, - "whatwg-fetch": { - "version": "3.6.20", - "resolved": "https://registry.npmmirror.com/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", - "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "wildcard": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-2.0.0.tgz", - "integrity": "sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==", - "dev": true - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "yaml": { - "version": "2.5.0", - "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.5.0.tgz", - "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", - "dev": true } } } diff --git a/pkg/download/engine/polyfill/package.json b/pkg/download/engine/polyfill/package.json index a19fbb58..a49faa56 100644 --- a/pkg/download/engine/polyfill/package.json +++ b/pkg/download/engine/polyfill/package.json @@ -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" } diff --git a/pkg/download/engine/polyfill/src/abort/index.js b/pkg/download/engine/polyfill/src/abort/index.js new file mode 100644 index 00000000..d56c9fee --- /dev/null +++ b/pkg/download/engine/polyfill/src/abort/index.js @@ -0,0 +1,2 @@ +import "../event/index.js"; +import "abortcontroller-polyfill/dist/abortcontroller-polyfill-only.js"; diff --git a/pkg/download/engine/polyfill/src/event/index.js b/pkg/download/engine/polyfill/src/event/index.js new file mode 100644 index 00000000..de6c4192 --- /dev/null +++ b/pkg/download/engine/polyfill/src/event/index.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; +} diff --git a/pkg/download/engine/polyfill/src/index.js b/pkg/download/engine/polyfill/src/index.js index 89596149..b989f17b 100644 --- a/pkg/download/engine/polyfill/src/index.js +++ b/pkg/download/engine/polyfill/src/index.js @@ -1,5 +1,7 @@ +import "./event/index.js" +import "./abort/index.js" import "./blob/index.js" import "./crypto/index.js" // polyfill TextEncoder import 'fastestsmallesttextencoderdecoder'; -import "./fetch/index.js" \ No newline at end of file +import "./fetch/index.js" diff --git a/pkg/download/engine/webview/provider.go b/pkg/download/engine/webview/provider.go new file mode 100644 index 00000000..15c756ad --- /dev/null +++ b/pkg/download/engine/webview/provider.go @@ -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 +} diff --git a/pkg/download/engine/webview/rpc.go b/pkg/download/engine/webview/rpc.go new file mode 100644 index 00000000..5dab17ef --- /dev/null +++ b/pkg/download/engine/webview/rpc.go @@ -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{} diff --git a/pkg/download/engine/webview/runtime.go b/pkg/download/engine/webview/runtime.go new file mode 100644 index 00000000..64a236e4 --- /dev/null +++ b/pkg/download/engine/webview/runtime.go @@ -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{} +} diff --git a/pkg/download/engine/webview/runtime_test.go b/pkg/download/engine/webview/runtime_test.go new file mode 100644 index 00000000..066dea2e --- /dev/null +++ b/pkg/download/engine/webview/runtime_test.go @@ -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", + "", + }, + 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 != "" { + 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 +} diff --git a/pkg/download/extension.go b/pkg/download/extension.go index 1cec437f..42d39b2c 100644 --- a/pkg/download/extension.go +++ b/pkg/download/extension.go @@ -13,8 +13,10 @@ import ( "github.com/GopeedLab/gopeed/internal/logger" "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/download/engine" gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error" gojautil "github.com/GopeedLab/gopeed/pkg/download/engine/util" + enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" "github.com/GopeedLab/gopeed/pkg/util" "github.com/dop251/goja" "github.com/go-git/go-git/v5" @@ -269,6 +271,7 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource, er } ensureResourceRequestRawURLs(req, ctx.Res) ctx.Res.CalcSize(nil) + d.applyGBlobResourceMetadata(ctx.Res) } res = ctx.Res }, @@ -276,6 +279,19 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource, er return } +func (d *Downloader) applyGBlobResourceMetadata(res *base.Resource) { + if d.gblob == nil || res == nil { + return + } + for _, file := range res.Files { + if file == nil || file.Req == nil { + continue + } + _ = d.gblob.SetSize(file.Req.URL, file.Size) + _ = d.gblob.SetRange(file.Req.URL, res.Range) + } +} + func (d *Downloader) triggerOnStart(task *Task) { doTrigger(d, EventOnStart, @@ -363,7 +379,10 @@ func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, c } engine, session := d.newExtensionEngine() defer session.CloseIfIdle() - err = engine.Runtime.Set("gopeed", gopeed) + gopeed.Runtime = &InstanceRuntime{ + WebView: d.newExtensionWebViewRuntime(session), + } + err = injectGopeed(engine.Runtime, gopeed) if err != nil { gopeed.Logger.logger.Error().Err(err).Msgf("[%s] engine inject failed", ext.buildIdentity()) return @@ -603,32 +622,37 @@ type Option struct { // Instance inject to js context when extension script is activated type Instance struct { - Events InstanceEvents `json:"events"` - Info *ExtensionInfo `json:"info"` - Logger *InstanceLogger `json:"logger"` - Settings map[string]any `json:"settings"` - Storage *ContextStorage `json:"storage"` + Events InstanceEvents `json:"events"` + Info *ExtensionInfo `json:"info"` + Logger *InstanceLogger `json:"logger"` + Settings map[string]any `json:"settings"` + Storage *ContextStorage `json:"storage"` + Runtime *InstanceRuntime `json:"runtime"` } -type InstanceEvents map[ActivationEvent]goja.Callable +type InstanceRuntime struct { + WebView *enginewebview.Runtime `json:"webview"` +} -func (h InstanceEvents) register(name ActivationEvent, fn goja.Callable) { +type InstanceEvents map[ActivationEvent]engine.JSFunction + +func (h InstanceEvents) register(name ActivationEvent, fn engine.JSFunction) { h[name] = fn } -func (h InstanceEvents) OnResolve(fn goja.Callable) { +func (h InstanceEvents) OnResolve(fn engine.JSFunction) { h.register(EventOnResolve, fn) } -func (h InstanceEvents) OnStart(fn goja.Callable) { +func (h InstanceEvents) OnStart(fn engine.JSFunction) { h.register(EventOnStart, fn) } -func (h InstanceEvents) OnError(fn goja.Callable) { +func (h InstanceEvents) OnError(fn engine.JSFunction) { h.register(EventOnError, fn) } -func (h InstanceEvents) OnDone(fn goja.Callable) { +func (h InstanceEvents) OnDone(fn engine.JSFunction) { h.register(EventOnDone, fn) } diff --git a/pkg/download/extension_engine.go b/pkg/download/extension_engine.go new file mode 100644 index 00000000..39b832f1 --- /dev/null +++ b/pkg/download/extension_engine.go @@ -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 +} diff --git a/pkg/download/extension_engine_session.go b/pkg/download/extension_engine_session.go index b7914cf4..fe011971 100644 --- a/pkg/download/extension_engine_session.go +++ b/pkg/download/extension_engine_session.go @@ -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 +} diff --git a/pkg/download/extension_runtime_webview.go b/pkg/download/extension_runtime_webview.go new file mode 100644 index 00000000..06ec585e --- /dev/null +++ b/pkg/download/extension_runtime_webview.go @@ -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 +} diff --git a/pkg/download/extension_test.go b/pkg/download/extension_test.go index c372dde1..3b3f5e60 100644 --- a/pkg/download/extension_test.go +++ b/pkg/download/extension_test.go @@ -1,15 +1,25 @@ package download import ( + "crypto/md5" + "encoding/hex" "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" "os" "path/filepath" + "strconv" + "strings" "testing" "time" "github.com/GopeedLab/gopeed/internal/logger" "github.com/GopeedLab/gopeed/pkg/base" gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error" + enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" "github.com/dop251/goja" ) @@ -94,10 +104,12 @@ func TestDownloader_Extension_GBlobBlob(t *testing.T) { if string(data) != "hello world" { t.Fatalf("unexpected blob download content: %q", string(data)) } + + waitForDirEmpty(t, filepath.Join(downloader.cfg.StorageDir, "gblob"), 5*time.Second) }) } -func TestDownloader_Extension_GBlobWritableStream(t *testing.T) { +func TestDownloader_Extension_GBlobReadableStream(t *testing.T) { setupDownloader(func(downloader *Downloader) { if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/gblob", false); err != nil { t.Fatal(err) @@ -113,6 +125,234 @@ func TestDownloader_Extension_GBlobWritableStream(t *testing.T) { t.Fatalf("expected empty resolve id for extension resource, got %q", rr.ID) } + dir := t.TempDir() + id, err := downloader.CreateDirect(rr.Res.Files[0].Req, &base.Options{ + Path: dir, + Name: rr.Res.Files[0].Name, + }) + if err != nil { + t.Fatal(err) + } + waitForTaskTerminal(t, downloader, id, 5*time.Second) + + data, err := os.ReadFile(filepath.Join(dir, "stream.txt")) + if err != nil { + t.Fatal(err) + } + if string(data) != "line 1\nline 2\n" { + t.Fatalf("unexpected stream download content: %q", string(data)) + } + }) +} + +func TestDownloader_Extension_GBlobReadableStreamUnknownSize(t *testing.T) { + setupDownloader(func(downloader *Downloader) { + if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/gblob", false); err != nil { + t.Fatal(err) + } + + rr, err := downloader.Resolve(&base.Request{ + URL: "https://example.com/stream-unknown", + }, nil) + if err != nil { + t.Fatal(err) + } + if rr.ID != "" { + t.Fatalf("expected empty resolve id for extension resource, got %q", rr.ID) + } + if got := rr.Res.Files[0].Size; got != 0 { + t.Fatalf("expected unknown size in resolve result, got %d", got) + } + + dir := t.TempDir() + id, err := downloader.CreateDirect(rr.Res.Files[0].Req, &base.Options{ + Path: dir, + Name: rr.Res.Files[0].Name, + }) + if err != nil { + t.Fatal(err) + } + + time.Sleep(60 * time.Millisecond) + task := downloader.GetTask(id) + if task == nil { + t.Fatal("task not found") + } + if task.Status == base.DownloadStatusDone { + t.Fatal("task finished before writer.close()") + } + waitForTaskTerminal(t, downloader, id, 5*time.Second) + + data, err := os.ReadFile(filepath.Join(dir, "stream-unknown.txt")) + if err != nil { + t.Fatal(err) + } + if string(data) != "line 1\nline 2\n" { + t.Fatalf("unexpected unknown-size stream content: %q", string(data)) + } + + task = downloader.GetTask(id) + if task == nil { + t.Fatal("task not found after completion") + } + if got := task.Meta.Res.Size; got != int64(len(data)) { + t.Fatalf("unexpected final task size: got %d want %d", got, len(data)) + } + }) +} + +func TestDownloader_Extension_GBlobSourceSizePropagatesToCreatedTask(t *testing.T) { + setupDownloader(func(downloader *Downloader) { + if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/gblob", false); err != nil { + t.Fatal(err) + } + + rr, err := downloader.Resolve(&base.Request{ + URL: "https://example.com/stream", + }, nil) + if err != nil { + t.Fatal(err) + } + if len(rr.Res.Files) != 1 { + t.Fatalf("unexpected resolved file count: %d", len(rr.Res.Files)) + } + + file := rr.Res.Files[0] + + dir := t.TempDir() + id, err := downloader.CreateDirect(file.Req, &base.Options{ + Path: dir, + Name: file.Name, + }) + if err != nil { + t.Fatal(err) + } + + task := downloader.GetTask(id) + if task == nil { + t.Fatal("task not found") + } + if task.Meta == nil || task.Meta.Res == nil { + t.Fatal("task meta resource not seeded") + } + if got := task.Meta.Res.Size; got != file.Size { + t.Fatalf("unexpected task size: got %d want %d", got, file.Size) + } + if len(task.Meta.Res.Files) != 1 || task.Meta.Res.Files[0].Size != file.Size { + t.Fatalf("unexpected task file metadata: %#v", task.Meta.Res.Files) + } + + waitForTaskTerminal(t, downloader, id, 5*time.Second) + }) +} + +func TestDownloader_Extension_GBlobReadableStreamRangeResume(t *testing.T) { + setupDownloader(func(downloader *Downloader) { + if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/gblob", false); err != nil { + t.Fatal(err) + } + + rr, err := downloader.Resolve(&base.Request{ + URL: "https://example.com/stream-range", + }, nil) + if err != nil { + t.Fatal(err) + } + if len(rr.Res.Files) != 1 { + t.Fatalf("unexpected resolved file count: %d", len(rr.Res.Files)) + } + if !rr.Res.Range { + t.Fatal("expected resumable gblob resource") + } + + file := rr.Res.Files[0] + + dir := t.TempDir() + id, err := downloader.CreateDirect(file.Req, &base.Options{ + Path: dir, + Name: file.Name, + }) + if err != nil { + t.Fatal(err) + } + + waitForTaskStatus(t, downloader, id, base.DownloadStatusError, 5*time.Second) + + filePath := filepath.Join(dir, file.Name) + waitForFileSizeAtLeast(t, filePath, int64(len("line 1\n")), 2*time.Second) + info, err := os.Stat(filePath) + if err != nil { + t.Fatal(err) + } + if info.Size() >= file.Size { + t.Fatalf("expected partial file before resume, got %d want <%d", info.Size(), file.Size) + } + + task := downloader.GetTask(id) + if task == nil { + t.Fatal("task not found after first error") + } + if task.Meta == nil || task.Meta.Res == nil || !task.Meta.Res.Range { + t.Fatal("expected resumable task metadata after first error") + } + + if err := downloader.Continue(&TaskFilter{IDs: []string{id}}); err != nil { + t.Fatal(err) + } + waitForTaskStatus(t, downloader, id, base.DownloadStatusDone, 5*time.Second) + + data, err := os.ReadFile(filePath) + if err != nil { + t.Fatal(err) + } + if string(data) != "line 1\nline 2\nline 3\n" { + t.Fatalf("unexpected resumed content: %q", string(data)) + } + }) +} + +func TestDownloader_Extension_GBlobHTTPStreamProxy(t *testing.T) { + setupDownloader(func(downloader *Downloader) { + if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/gblob", false); err != nil { + t.Fatal(err) + } + + payload := strings.Repeat("gopeed-stream-", 32*1024) + expectedMD5 := calcExtensionTestMD5(strings.NewReader(payload)) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(payload))) + flusher, _ := w.(http.Flusher) + chunkSize := 8192 + for start := 0; start < len(payload); start += chunkSize { + end := start + chunkSize + if end > len(payload) { + end = len(payload) + } + if _, err := io.WriteString(w, payload[start:end]); err != nil { + return + } + if flusher != nil { + flusher.Flush() + } + time.Sleep(2 * time.Millisecond) + } + })) + defer server.Close() + + rr, err := downloader.Resolve(&base.Request{ + URL: "https://example.com/http-stream?target=" + server.URL + "&name=proxy.bin", + }, nil) + if err != nil { + t.Fatal(err) + } + if rr.ID != "" { + t.Fatalf("expected empty resolve id for extension resource, got %q", rr.ID) + } + if len(rr.Res.Files) != 1 { + t.Fatalf("unexpected resolved file count: %d", len(rr.Res.Files)) + } + doneCh := make(chan error, 1) downloader.Listener(func(event *Event) { if event.Key == EventKeyDone || event.Key == EventKeyError { @@ -133,45 +373,67 @@ func TestDownloader_Extension_GBlobWritableStream(t *testing.T) { if err != nil { t.Fatal(err) } - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for gblob writable stream download") + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for gblob http stream proxy download") } - data, err := os.ReadFile(filepath.Join(dir, "stream.txt")) + file, err := os.Open(filepath.Join(dir, "proxy.bin")) if err != nil { t.Fatal(err) } - if string(data) != "line 1\nline 2\n" { - t.Fatalf("unexpected stream download content: %q", string(data)) + defer file.Close() + if got := calcExtensionTestMD5(file); got != expectedMD5 { + t.Fatalf("unexpected proxied download md5: got %s want %s", got, expectedMD5) } }) } -func TestDownloader_Extension_GBlobWritableStreamUnknownSize(t *testing.T) { +func TestDownloader_Extension_GBlobHTTPStreamProxyReportsDownloadedBeforeCompletion(t *testing.T) { setupDownloader(func(downloader *Downloader) { + downloader.cfg.RefreshInterval = 50 + if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/gblob", false); err != nil { t.Fatal(err) } + payload := strings.Repeat("gopeed-progress-", 64*1024) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + w.Header().Set("Connection", "close") + w.Header().Set("Content-Length", strconv.Itoa(len(payload))) + return + } + + w.Header().Set("Connection", "close") + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(payload))) + flusher, _ := w.(http.Flusher) + chunkSize := 4096 + for start := 0; start < len(payload); start += chunkSize { + end := start + chunkSize + if end > len(payload) { + end = len(payload) + } + if _, err := io.WriteString(w, payload[start:end]); err != nil { + return + } + if flusher != nil { + flusher.Flush() + } + time.Sleep(30 * time.Millisecond) + } + })) + defer server.Close() + rr, err := downloader.Resolve(&base.Request{ - URL: "https://example.com/stream-unknown", + URL: "https://example.com/http-stream?target=" + server.URL + "&name=progress.bin", }, nil) if err != nil { t.Fatal(err) } - if rr.ID != "" { - t.Fatalf("expected empty resolve id for extension resource, got %q", rr.ID) + if len(rr.Res.Files) != 1 { + t.Fatalf("unexpected resolved file count: %d", len(rr.Res.Files)) } - if got := rr.Res.Files[0].Size; got != 0 { - t.Fatalf("expected unknown size in resolve result, got %d", got) - } - - doneCh := make(chan error, 1) - downloader.Listener(func(event *Event) { - if event.Key == EventKeyDone || event.Key == EventKeyError { - doneCh <- event.Err - } - }) dir := t.TempDir() id, err := downloader.CreateDirect(rr.Res.Files[0].Req, &base.Options{ @@ -182,43 +444,596 @@ func TestDownloader_Extension_GBlobWritableStreamUnknownSize(t *testing.T) { t.Fatal(err) } - time.Sleep(60 * time.Millisecond) - task := downloader.GetTask(id) - if task == nil { - t.Fatal("task not found") - } - if task.Status == base.DownloadStatusDone { - t.Fatal("task finished before writer.close()") - } + waitForTaskStatus(t, downloader, id, base.DownloadStatusRunning, 2*time.Second) - select { - case err := <-doneCh: - if err != nil { - t.Fatal(err) + deadline := time.Now().Add(2 * time.Second) + var observed int64 + for time.Now().Before(deadline) { + task := downloader.GetTask(id) + if task == nil { + t.Fatal("task not found") } - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for unknown-size writable stream download") + observed = task.Progress.Downloaded + if observed > 0 { + if task.Status == base.DownloadStatusDone { + t.Fatal("expected slow proxy task to still be running when intermediate downloaded bytes become visible") + } + break + } + time.Sleep(20 * time.Millisecond) + } + if observed <= 0 { + _ = downloader.Delete(&TaskFilter{IDs: []string{id}}, true) + t.Fatalf("expected downloaded bytes > 0 before completion, got %d", observed) } - data, err := os.ReadFile(filepath.Join(dir, "stream-unknown.txt")) + waitForTaskTerminal(t, downloader, id, 10*time.Second) + }) +} + +func TestDownloader_Extension_GBlobHTTPBlobReportsDownloadedBeforeCompletion(t *testing.T) { + setupDownloader(func(downloader *Downloader) { + downloader.cfg.RefreshInterval = 50 + + if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/gblob", false); err != nil { + t.Fatal(err) + } + + payload := strings.Repeat("gopeed-blob-progress-", 64*1024) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + w.Header().Set("Connection", "close") + w.Header().Set("Content-Length", strconv.Itoa(len(payload))) + return + } + + w.Header().Set("Connection", "close") + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(payload))) + flusher, _ := w.(http.Flusher) + chunkSize := 4096 + for start := 0; start < len(payload); start += chunkSize { + end := start + chunkSize + if end > len(payload) { + end = len(payload) + } + if _, err := io.WriteString(w, payload[start:end]); err != nil { + return + } + if flusher != nil { + flusher.Flush() + } + time.Sleep(30 * time.Millisecond) + } + })) + defer server.Close() + + rr, err := downloader.Resolve(&base.Request{ + URL: "https://example.com/http-blob?target=" + server.URL + "&name=progress-blob.bin", + }, nil) if err != nil { t.Fatal(err) } - if string(data) != "line 1\nline 2\n" { - t.Fatalf("unexpected unknown-size stream content: %q", string(data)) + if len(rr.Res.Files) != 1 { + t.Fatalf("unexpected resolved file count: %d", len(rr.Res.Files)) } - task = downloader.GetTask(id) - if task == nil { - t.Fatal("task not found after completion") + dir := t.TempDir() + id, err := downloader.CreateDirect(rr.Res.Files[0].Req, &base.Options{ + Path: dir, + Name: rr.Res.Files[0].Name, + }) + if err != nil { + t.Fatal(err) } - if got := task.Meta.Res.Size; got != int64(len(data)) { - t.Fatalf("unexpected final task size: got %d want %d", got, len(data)) + + waitForTaskStatus(t, downloader, id, base.DownloadStatusRunning, 2*time.Second) + + deadline := time.Now().Add(2 * time.Second) + var observed int64 + for time.Now().Before(deadline) { + task := downloader.GetTask(id) + if task == nil { + t.Fatal("task not found") + } + observed = task.Progress.Downloaded + if observed > 0 { + if task.Status == base.DownloadStatusDone { + t.Fatal("expected slow blob task to still be running when intermediate downloaded bytes become visible") + } + break + } + time.Sleep(20 * time.Millisecond) + } + if observed <= 0 { + _ = downloader.Delete(&TaskFilter{IDs: []string{id}}, true) + t.Fatalf("expected downloaded bytes > 0 before completion, got %d", observed) + } + + waitForTaskTerminal(t, downloader, id, 10*time.Second) + }) +} + +func TestDownloader_Extension_GBlobHTTPStreamPairReportsDownloadedConcurrently(t *testing.T) { + setupDownloader(func(downloader *Downloader) { + downloader.cfg.RefreshInterval = 50 + + if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/gblob", false); err != nil { + t.Fatal(err) + } + + payload := strings.Repeat("gopeed-pair-progress-", 64*1024) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + w.Header().Set("Content-Length", strconv.Itoa(len(payload))) + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(payload))) + flusher, _ := w.(http.Flusher) + chunkSize := 4096 + for start := 0; start < len(payload); start += chunkSize { + end := start + chunkSize + if end > len(payload) { + end = len(payload) + } + if _, err := io.WriteString(w, payload[start:end]); err != nil { + return + } + if flusher != nil { + flusher.Flush() + } + time.Sleep(25 * time.Millisecond) + } + })) + defer server.Close() + + rr, err := downloader.Resolve(&base.Request{ + URL: "https://example.com/http-stream?pair=1&target=" + url.QueryEscape(server.URL), + }, nil) + if err != nil { + t.Fatal(err) + } + if len(rr.Res.Files) != 2 { + t.Fatalf("unexpected resolved file count: %d", len(rr.Res.Files)) + } + + dir := t.TempDir() + var ids []string + for _, file := range rr.Res.Files { + id, err := downloader.CreateDirect(file.Req, &base.Options{ + Path: dir, + Name: file.Name, + }) + if err != nil { + t.Fatal(err) + } + ids = append(ids, id) + } + + for _, id := range ids { + waitForTaskStatus(t, downloader, id, base.DownloadStatusRunning, 2*time.Second) + } + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + task1 := downloader.GetTask(ids[0]) + task2 := downloader.GetTask(ids[1]) + if task1 == nil || task2 == nil { + t.Fatal("pair task not found") + } + if task1.Status == base.DownloadStatusDone && task2.Progress.Downloaded == 0 { + t.Fatalf("second task never reported downloaded bytes before first completed: task1=%d task2=%d", task1.Progress.Downloaded, task2.Progress.Downloaded) + } + if task2.Status == base.DownloadStatusDone && task1.Progress.Downloaded == 0 { + t.Fatalf("first task never reported downloaded bytes before second completed: task1=%d task2=%d", task1.Progress.Downloaded, task2.Progress.Downloaded) + } + if task1.Progress.Downloaded > 0 && task2.Progress.Downloaded > 0 { + waitForTaskStatus(t, downloader, ids[0], base.DownloadStatusDone, 10*time.Second) + waitForTaskStatus(t, downloader, ids[1], base.DownloadStatusDone, 10*time.Second) + return + } + time.Sleep(20 * time.Millisecond) + } + + task1 := downloader.GetTask(ids[0]) + task2 := downloader.GetTask(ids[1]) + t.Fatalf("expected both tasks to report downloaded bytes before completion, got task1=%d task2=%d status1=%s status2=%s", task1.Progress.Downloaded, task2.Progress.Downloaded, task1.Status, task2.Status) + }) +} + +func TestDownloader_Extension_GBlobHTTPStreamDeleteWhileDownloading(t *testing.T) { + setupDownloader(func(downloader *Downloader) { + downloader.cfg.RefreshInterval = 50 + + if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/gblob", false); err != nil { + t.Fatal(err) + } + + payload := strings.Repeat("gopeed-delete-", 64*1024) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + w.Header().Set("Content-Length", strconv.Itoa(len(payload))) + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(payload))) + flusher, _ := w.(http.Flusher) + chunkSize := 4096 + for start := 0; start < len(payload); start += chunkSize { + end := start + chunkSize + if end > len(payload) { + end = len(payload) + } + if _, err := io.WriteString(w, payload[start:end]); err != nil { + return + } + if flusher != nil { + flusher.Flush() + } + time.Sleep(20 * time.Millisecond) + } + })) + server.Config.SetKeepAlivesEnabled(false) + defer server.Close() + defer server.CloseClientConnections() + + rr, err := downloader.Resolve(&base.Request{ + URL: "https://example.com/http-stream?target=" + server.URL + "&name=delete.bin", + }, nil) + if err != nil { + t.Fatal(err) + } + if len(rr.Res.Files) != 1 { + t.Fatalf("unexpected resolved file count: %d", len(rr.Res.Files)) + } + + dir := t.TempDir() + id, err := downloader.CreateDirect(rr.Res.Files[0].Req, &base.Options{ + Path: dir, + Name: rr.Res.Files[0].Name, + }) + if err != nil { + t.Fatal(err) + } + + waitForTaskStatus(t, downloader, id, base.DownloadStatusRunning, 2*time.Second) + + filePath := filepath.Join(dir, rr.Res.Files[0].Name) + waitForFileSizeAtLeast(t, filePath, 4096, 2*time.Second) + + if err := downloader.Delete(&TaskFilter{IDs: []string{id}}, false); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if downloader.GetTask(id) == nil { + waitForDirEmpty(t, filepath.Join(downloader.cfg.StorageDir, "gblob"), 5*time.Second) + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("timeout waiting for task %s to be deleted", id) + }) +} + +func TestDownloader_Extension_GBlobHTTPStreamRangePauseAndContinue(t *testing.T) { + setupDownloader(func(downloader *Downloader) { + if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/gblob", false); err != nil { + t.Fatal(err) + } + + payload := strings.Repeat("gopeed-range-stream-", 16*1024) + expectedMD5 := calcExtensionTestMD5(strings.NewReader(payload)) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Length", strconv.Itoa(len(payload))) + return + } + + data := []byte(payload) + start := 0 + status := http.StatusOK + rangeHeader := r.Header.Get("Range") + if strings.HasPrefix(rangeHeader, "bytes=") { + startValue := strings.TrimSuffix(strings.TrimPrefix(rangeHeader, "bytes="), "-") + parsed, err := strconv.Atoi(startValue) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + start = parsed + if start > len(data) { + w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + return + } + status = http.StatusPartialContent + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, len(data)-1, len(data))) + } + + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(data)-start)) + w.WriteHeader(status) + + flusher, _ := w.(http.Flusher) + chunkSize := 4096 + for offset := start; offset < len(data); offset += chunkSize { + end := offset + chunkSize + if end > len(data) { + end = len(data) + } + if _, err := w.Write(data[offset:end]); err != nil { + return + } + if flusher != nil { + flusher.Flush() + } + time.Sleep(5 * time.Millisecond) + } + })) + defer server.Close() + + rr, err := downloader.Resolve(&base.Request{ + URL: fmt.Sprintf( + "https://example.com/http-stream-range?target=%s&name=proxy-range.bin&size=%d", + url.QueryEscape(server.URL), + len(payload), + ), + }, nil) + if err != nil { + t.Fatal(err) + } + if len(rr.Res.Files) != 1 || !rr.Res.Range { + t.Fatalf("unexpected resumable resolve result: %#v", rr.Res) + } + + file := rr.Res.Files[0] + + dir := t.TempDir() + id, err := downloader.CreateDirect(file.Req, &base.Options{ + Path: dir, + Name: file.Name, + }) + if err != nil { + t.Fatal(err) + } + + filePath := filepath.Join(dir, file.Name) + waitForFileSizeAtLeast(t, filePath, 4096, 5*time.Second) + if err := downloader.Pause(&TaskFilter{IDs: []string{id}}); err != nil { + t.Fatal(err) + } + waitForTaskStatus(t, downloader, id, base.DownloadStatusPause, 2*time.Second) + + stat, err := os.Stat(filePath) + if err != nil { + t.Fatal(err) + } + pausedSize := stat.Size() + + time.Sleep(250 * time.Millisecond) + + stat, err = os.Stat(filePath) + if err != nil { + t.Fatal(err) + } + if stat.Size() != pausedSize { + t.Fatalf("expected paused file size to remain %d, got %d", pausedSize, stat.Size()) + } + + if err := downloader.Continue(&TaskFilter{IDs: []string{id}}); err != nil { + t.Fatal(err) + } + waitForTaskStatus(t, downloader, id, base.DownloadStatusDone, 10*time.Second) + + output, err := os.Open(filePath) + if err != nil { + t.Fatal(err) + } + defer output.Close() + if got := calcExtensionTestMD5(output); got != expectedMD5 { + t.Fatalf("unexpected resumed range download md5: got %s want %s", got, expectedMD5) } }) } -func TestDownloader_Extension_GBlobWritableStreamPauseAndContinue(t *testing.T) { +func TestDownloader_Extension_GBlobResumeAfterRestartViaOnError(t *testing.T) { + storageDir := t.TempDir() + downloadDir := t.TempDir() + + payload := strings.Repeat("gopeed-restart-range-", 16*1024) + expectedMD5 := calcExtensionTestMD5(strings.NewReader(payload)) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/restart-range") { + http.NotFound(w, r) + return + } + + if r.Method == http.MethodHead { + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Length", strconv.Itoa(len(payload))) + return + } + + data := []byte(payload) + start := 0 + status := http.StatusOK + rangeHeader := r.Header.Get("Range") + if strings.HasPrefix(rangeHeader, "bytes=") { + startValue := strings.TrimSuffix(strings.TrimPrefix(rangeHeader, "bytes="), "-") + parsed, err := strconv.Atoi(startValue) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + start = parsed + if start > len(data) { + w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + return + } + status = http.StatusPartialContent + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, len(data)-1, len(data))) + } + + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(data)-start)) + w.WriteHeader(status) + + flusher, _ := w.(http.Flusher) + chunkSize := 4096 + for offset := start; offset < len(data); offset += chunkSize { + end := offset + chunkSize + if end > len(data) { + end = len(data) + } + if _, err := w.Write(data[offset:end]); err != nil { + return + } + if flusher != nil { + flusher.Flush() + } + time.Sleep(20 * time.Millisecond) + } + })) + defer server.Close() + + newDownloader := func() *Downloader { + downloader := NewDownloader(&DownloaderConfig{ + Storage: NewBoltStorage(storageDir), + StorageDir: storageDir, + DownloaderStoreConfig: &base.DownloaderStoreConfig{ + DownloadDir: downloadDir, + }, + }) + downloader.cfg.RefreshInterval = 50 + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + return downloader + } + + downloader := newDownloader() + if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/gblob_restart", false); err != nil { + t.Fatal(err) + } + + rr, err := downloader.Resolve(&base.Request{ + URL: server.URL + "/restart-range", + }, nil) + if err != nil { + t.Fatal(err) + } + if len(rr.Res.Files) != 1 || !rr.Res.Range { + t.Fatalf("unexpected resolve result: %#v", rr.Res) + } + + file := rr.Res.Files[0] + id, err := downloader.CreateDirect(file.Req, &base.Options{ + Path: downloadDir, + Name: file.Name, + }) + if err != nil { + t.Fatal(err) + } + + waitForTaskStatus(t, downloader, id, base.DownloadStatusRunning, 2*time.Second) + + deadline := time.Now().Add(2 * time.Second) + var partialSize int64 + for time.Now().Before(deadline) { + task := downloader.GetTask(id) + if task == nil { + t.Fatal("task not found") + } + partialSize = task.Progress.Downloaded + if partialSize > 0 && partialSize < int64(len(payload)) { + break + } + time.Sleep(20 * time.Millisecond) + } + if partialSize <= 0 || partialSize >= int64(len(payload)) { + t.Fatalf("expected partial downloaded bytes before restart, got %d", partialSize) + } + + if err := downloader.Close(); err != nil { + t.Fatal(err) + } + + downloader = newDownloader() + defer func() { + _ = downloader.Clear() + os.RemoveAll(storageDir) + os.RemoveAll(downloadDir) + }() + if len(downloader.GetExtensions()) == 0 { + t.Fatal("expected restored downloader to load extensions") + } + + task := downloader.GetTask(id) + if task == nil { + t.Fatal("restored task not found") + } + if task.Status != base.DownloadStatusError { + t.Fatalf("expected restored gblob task to become error, got %s", task.Status) + } + if task.Protocol != "gblob" { + t.Fatalf("expected restored task protocol gblob, got %s", task.Protocol) + } + if task.Meta == nil || task.Meta.Req == nil { + t.Fatal("restored task request missing") + } + if task.Meta.Req.RawURL == "" { + t.Fatal("restored task raw url missing") + } + if task.Meta.Req.Labels == nil || task.Meta.Req.Labels["mode"] != "restart" { + t.Fatalf("unexpected restored task labels: %#v", task.Meta.Req.Labels) + } + oldURL := task.Meta.Req.URL + + if err := downloader.Continue(&TaskFilter{IDs: []string{id}}); err != nil { + t.Fatal(err) + } + + deadline = time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + task = downloader.GetTask(id) + if task == nil { + t.Fatal("restored task not found after continue") + } + if task.Meta != nil && task.Meta.Req != nil && task.Meta.Req.Labels["started"] != "" { + break + } + time.Sleep(20 * time.Millisecond) + } + if task == nil || task.Meta == nil || task.Meta.Req == nil || task.Meta.Req.Labels["started"] == "" { + t.Fatalf("expected onError recovery to run, labels=%#v old=%q current=%q", task.Meta.Req.Labels, oldURL, task.Meta.Req.URL) + } + if task.Meta.Req.Labels["rebuilt"] != "true" { + t.Fatalf("expected onError recovery to rebuild gblob URL, labels=%#v old=%q current=%q rebuildError=%q", task.Meta.Req.Labels, oldURL, task.Meta.Req.URL, task.Meta.Req.Labels["rebuildError"]) + } + if task.Meta.Req.URL == oldURL { + t.Fatalf("expected onError recovery to rebuild gblob URL, old=%q current=%q labels=%#v", oldURL, task.Meta.Req.URL, task.Meta.Req.Labels) + } + + waitForTaskStatus(t, downloader, id, base.DownloadStatusDone, 10*time.Second) + + outputFile, err := os.Open(filepath.Join(downloadDir, file.Name)) + if err != nil { + t.Fatal(err) + } + defer outputFile.Close() + if got := calcExtensionTestMD5(outputFile); got != expectedMD5 { + t.Fatalf("unexpected resumed download md5: got %s want %s", got, expectedMD5) + } +} + +func TestDownloader_Extension_GBlobReadableStreamPauseAndContinue(t *testing.T) { setupDownloader(func(downloader *Downloader) { if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/gblob", false); err != nil { t.Fatal(err) @@ -231,13 +1046,6 @@ func TestDownloader_Extension_GBlobWritableStreamPauseAndContinue(t *testing.T) t.Fatal(err) } - doneCh := make(chan error, 1) - downloader.Listener(func(event *Event) { - if event.Key == EventKeyDone || event.Key == EventKeyError { - doneCh <- event.Err - } - }) - dir := t.TempDir() id, err := downloader.CreateDirect(rr.Res.Files[0].Req, &base.Options{ Path: dir, @@ -281,15 +1089,7 @@ func TestDownloader_Extension_GBlobWritableStreamPauseAndContinue(t *testing.T) if err := downloader.Continue(&TaskFilter{IDs: []string{id}}); err != nil { t.Fatal(err) } - - select { - case err := <-doneCh: - if err != nil { - t.Fatal(err) - } - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for pause/continue writable stream download") - } + waitForTaskTerminal(t, downloader, id, 5*time.Second) data, err := os.ReadFile(filePath) if err != nil { @@ -314,13 +1114,6 @@ func TestDownloader_Extension_GBlobRecoverOnError(t *testing.T) { t.Fatal(err) } - doneCh := make(chan error, 1) - downloader.Listener(func(event *Event) { - if event.Key == EventKeyDone || event.Key == EventKeyError { - doneCh <- event.Err - } - }) - dir := t.TempDir() id, err := downloader.CreateDirect(rr.Res.Files[0].Req, &base.Options{ Path: dir, @@ -330,16 +1123,36 @@ func TestDownloader_Extension_GBlobRecoverOnError(t *testing.T) { t.Fatal(err) } - select { - case err := <-doneCh: - if err != nil { - t.Fatal(err) + filePath := filepath.Join(dir, "recover.txt") + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + task := downloader.GetTask(id) + if task != nil && task.Status == base.DownloadStatusDone { + break } - case <-time.After(5 * time.Second): - t.Fatal("timeout waiting for recovered gblob download") + time.Sleep(20 * time.Millisecond) + } + + task := downloader.GetTask(id) + if task == nil { + t.Fatal("task not found after recovery") + } + if task.Status != base.DownloadStatusDone { + var fileSize int64 = -1 + if info, statErr := os.Stat(filePath); statErr == nil { + fileSize = info.Size() + } + t.Fatalf( + "timeout waiting for recovered gblob download: status=%s downloaded=%d url=%q rawUrl=%q labels=%#v fileSize=%d", + task.Status, + task.Progress.Downloaded, + task.Meta.Req.URL, + task.Meta.Req.RawURL, + task.Meta.Req.Labels, + fileSize, + ) } - filePath := filepath.Join(dir, "recover.txt") data, err := os.ReadFile(filePath) if err != nil { t.Fatal(err) @@ -348,10 +1161,6 @@ func TestDownloader_Extension_GBlobRecoverOnError(t *testing.T) { t.Fatalf("unexpected recovered file content: %q", string(data)) } - task := downloader.GetTask(id) - if task == nil { - t.Fatal("task not found after recovery") - } if task.Status != base.DownloadStatusDone { t.Fatalf("expected recovered task done, got %s", task.Status) } @@ -762,16 +1571,426 @@ func TestDownloader_Extension_Logger(t *testing.T) { il.Error(goja.NaN(), goja.Undefined()) } +func TestDownloader_ExtensionRuntimeWebViewInjected(t *testing.T) { + downloader, cleanup, err := newTestExtensionEngineDownloader() + if err != nil { + t.Fatal(err) + } + defer cleanup() + + runtime, err := newTestExtensionEngine(t, downloader) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + + value, err := runtime.Eval(`({ + hasRuntime: !!gopeed.runtime, + hasWebView: !!(gopeed.runtime && gopeed.runtime.webview), + hasOpen: typeof gopeed.runtime.webview.open, + hasWebViewIsAvailable: typeof gopeed.runtime.webview.isAvailable, + webViewAvailable: gopeed.runtime.webview.isAvailable() + })`) + if err != nil { + t.Fatal(err) + } + + result, ok := value.(map[string]any) + if !ok { + t.Fatalf("unexpected eval result type: %T", value) + } + if result["hasRuntime"] != true { + t.Fatalf("expected runtime injection, got %#v", result) + } + if result["hasWebView"] != true { + t.Fatalf("expected webview injection, got %#v", result) + } + if result["hasOpen"] != "function" || result["hasWebViewIsAvailable"] != "function" { + t.Fatalf("expected webview api functions, got %#v", result) + } + if result["webViewAvailable"] != false { + t.Fatalf("expected unavailable webview runtime by default, got %#v", result["webViewAvailable"]) + } +} + +func TestDownloader_ExtensionRuntimeWebViewAvailabilityFromProvider(t *testing.T) { + downloader := NewDownloader(&DownloaderConfig{ + Storage: NewMemStorage(), + WebViewProvider: fakeExtensionWebViewProvider{ + available: true, + opener: &fakeRuntimeWebViewOpener{}, + }, + }) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + runtime, err := newTestExtensionEngine(t, downloader) + 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 available webview runtime, got %#v", value) + } +} + +func TestDownloader_ExtensionRuntimeWebViewPageMethodsInjected(t *testing.T) { + downloader := NewDownloader(&DownloaderConfig{ + Storage: NewMemStorage(), + WebViewProvider: fakeExtensionWebViewProvider{ + available: true, + opener: &fakeRuntimeWebViewOpener{}, + }, + }) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + runtime, err := newTestExtensionEngine(t, downloader) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + + value, err := runtime.Eval(`(async () => { + const page = await gopeed.runtime.webview.open(); + return { + hasFocus: typeof page.focus, + hasClick: typeof page.click, + hasType: typeof page.type, + hasWaitForSelector: typeof page.waitForSelector, + hasWaitForFunction: typeof page.waitForFunction, + hasWaitForLoad: typeof page.waitForLoad, + }; + })()`) + if err != nil { + t.Fatal(err) + } + + result, ok := value.(map[string]any) + if !ok { + t.Fatalf("unexpected page methods result type: %T", value) + } + for _, key := range []string{ + "hasFocus", + "hasClick", + "hasType", + "hasWaitForSelector", + "hasWaitForFunction", + } { + if result[key] != "function" { + t.Fatalf("expected %s to be a function, got %#v", key, result[key]) + } + } + if result["hasWaitForLoad"] != "undefined" { + t.Fatalf("expected hasWaitForLoad to be undefined, got %#v", result["hasWaitForLoad"]) + } +} + +func TestDownloader_ExtensionRuntimeWebViewExecuteAnonymousFunction(t *testing.T) { + opener := &capturingRuntimeWebViewOpener{ + page: &capturingRuntimeWebViewPage{ + executeValue: map[string]any{ + "title": "Hello", + "url": "https://example.com", + "userAgent": "UA", + "readyState": "complete", + }, + }, + } + downloader := NewDownloader(&DownloaderConfig{ + Storage: NewMemStorage(), + WebViewProvider: fakeExtensionWebViewProvider{ + available: true, + opener: opener, + }, + }) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + runtime, err := newTestExtensionEngine(t, downloader) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + + value, err := runtime.Eval(`(async () => { + const page = await gopeed.runtime.webview.open(); + return await page.execute(() => ({ + title: document.title || "", + url: String(location.href || ""), + userAgent: navigator.userAgent, + readyState: document.readyState, + })); + })()`) + if err != nil { + t.Fatal(err) + } + result, ok := value.(map[string]any) + if !ok { + t.Fatalf("unexpected execute result type: %T", value) + } + if result["title"] != "Hello" || result["readyState"] != "complete" { + t.Fatalf("unexpected execute result: %#v", result) + } + expected := `(() => ({ + title: document.title || "", + url: String(location.href || ""), + userAgent: navigator.userAgent, + readyState: document.readyState, + }))` + if opener.page.lastExecuteSource != expected { + t.Fatalf("unexpected execute source: %q", opener.page.lastExecuteSource) + } +} + +func TestDownloader_TriggerOnResolve_DetachedAsyncWorkDoesNotBlock(t *testing.T) { + setupDownloader(func(downloader *Downloader) { + extDir := t.TempDir() + manifest := `{ + "name": "detached-async-resolve", + "author": "gopeed", + "title": "Detached Async Resolve", + "version": "0.0.1", + "scripts": [ + { + "event": "onResolve", + "match": { + "urls": ["*://example.com/*"] + }, + "entry": "index.js" + } + ] +}` + script := `gopeed.events.onResolve(async (ctx) => { + (async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + globalThis.__detachedDone = true; + })(); + ctx.res = { + name: 'done', + files: [ + { + name: 'out.txt', + req: { + url: 'https://example.com/file.txt' + } + } + ] + }; +});` + if err := os.WriteFile(filepath.Join(extDir, "manifest.json"), []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(extDir, "index.js"), []byte(script), 0644); err != nil { + t.Fatal(err) + } + if _, err := downloader.InstallExtensionByFolder(extDir, false); err != nil { + t.Fatal(err) + } + + type result struct { + res *base.Resource + err error + } + done := make(chan result, 1) + startedAt := time.Now() + go func() { + res, err := downloader.triggerOnResolve(&base.Request{URL: "https://example.com/test"}) + done <- result{res: res, err: err} + }() + + select { + case out := <-done: + if out.err != nil { + t.Fatal(out.err) + } + if time.Since(startedAt) > 200*time.Millisecond { + t.Fatalf("triggerOnResolve blocked for %s", time.Since(startedAt)) + } + if out.res == nil || len(out.res.Files) != 1 { + t.Fatalf("unexpected resource: %#v", out.res) + } + case <-time.After(1 * time.Second): + t.Fatal("timeout waiting for triggerOnResolve") + } + }) +} + +type fakeExtensionWebViewProvider struct { + available bool + opener enginewebview.Opener +} + +func (p fakeExtensionWebViewProvider) IsAvailable() bool { + return p.available +} + +func (p fakeExtensionWebViewProvider) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) { + if p.opener == nil { + return nil, nil + } + return p.opener.Open(opts) +} + +type fakeRuntimeWebViewOpener struct{} + +func (fakeRuntimeWebViewOpener) Open(enginewebview.OpenOptions) (enginewebview.Page, error) { + return fakeRuntimeWebViewPage{}, nil +} + +type fakeRuntimeWebViewPage struct{} + +func (fakeRuntimeWebViewPage) AddInitScript(string) error { + return nil +} + +func (fakeRuntimeWebViewPage) Goto(string, enginewebview.GotoOptions) error { + return nil +} + +func (fakeRuntimeWebViewPage) Execute(string, ...any) (any, error) { + return nil, nil +} + +func (fakeRuntimeWebViewPage) GetCookies() ([]enginewebview.Cookie, error) { + return nil, nil +} + +func (fakeRuntimeWebViewPage) SetCookie(enginewebview.Cookie) error { + return nil +} + +func (fakeRuntimeWebViewPage) DeleteCookie(enginewebview.Cookie) error { + return nil +} + +func (fakeRuntimeWebViewPage) ClearCookies() error { + return nil +} + +func (fakeRuntimeWebViewPage) Close() error { + return nil +} + +type capturingRuntimeWebViewOpener struct { + page *capturingRuntimeWebViewPage +} + +func (o *capturingRuntimeWebViewOpener) Open(enginewebview.OpenOptions) (enginewebview.Page, error) { + return o.page, nil +} + +type capturingRuntimeWebViewPage struct { + lastExecuteSource string + lastExecuteArgs []any + executeValue any +} + +func (p *capturingRuntimeWebViewPage) AddInitScript(string) error { + return nil +} + +func (p *capturingRuntimeWebViewPage) Goto(string, enginewebview.GotoOptions) error { + return nil +} + +func (p *capturingRuntimeWebViewPage) Execute(expression string, args ...any) (any, error) { + p.lastExecuteSource = expression + p.lastExecuteArgs = args + return p.executeValue, nil +} + +func (p *capturingRuntimeWebViewPage) GetCookies() ([]enginewebview.Cookie, error) { + return nil, nil +} + +func (p *capturingRuntimeWebViewPage) SetCookie(enginewebview.Cookie) error { + return nil +} + +func (p *capturingRuntimeWebViewPage) DeleteCookie(enginewebview.Cookie) error { + return nil +} + +func (p *capturingRuntimeWebViewPage) ClearCookies() error { + return nil +} + +func (p *capturingRuntimeWebViewPage) Close() error { + return nil +} + func setupDownloader(fn func(downloader *Downloader)) { - defaultDownloader.Setup() - defaultDownloader.cfg.StorageDir = ".test_storage" - defaultDownloader.cfg.DownloadDir = ".test_download" + storageDir, err := os.MkdirTemp("", "gopeed-test-storage-") + if err != nil { + panic(err) + } + downloadDir, err := os.MkdirTemp("", "gopeed-test-download-") + if err != nil { + _ = os.RemoveAll(storageDir) + panic(err) + } + downloader := NewDownloader(&DownloaderConfig{ + StorageDir: storageDir, + }) + if err := downloader.Setup(); err != nil { + _ = os.RemoveAll(storageDir) + _ = os.RemoveAll(downloadDir) + panic(err) + } + cfg, err := downloader.GetConfig() + if err != nil { + _ = os.RemoveAll(storageDir) + _ = os.RemoveAll(downloadDir) + panic(err) + } + cfg.DownloadDir = downloadDir + if err := downloader.PutConfig(cfg); err != nil { + _ = os.RemoveAll(storageDir) + _ = os.RemoveAll(downloadDir) + panic(err) + } defer func() { - defaultDownloader.Clear() - os.RemoveAll(defaultDownloader.cfg.StorageDir) - os.RemoveAll(defaultDownloader.cfg.DownloadDir) + downloader.Clear() + os.RemoveAll(downloader.cfg.StorageDir) + os.RemoveAll(downloadDir) }() - fn(defaultDownloader) + fn(downloader) +} + +func newTestExtensionEngineDownloader() (*Downloader, func(), error) { + downloader := NewDownloader(&DownloaderConfig{ + Storage: NewMemStorage(), + }) + if err := downloader.Setup(); err != nil { + return nil, nil, err + } + cleanup := func() { + _ = downloader.Clear() + } + return downloader, cleanup, nil +} + +func newTestExtensionEngine(t *testing.T, downloader *Downloader) (*ExtensionEngine, error) { + t.Helper() + return downloader.NewExtensionEngine(&Extension{ + Name: "test-runtime", + Author: "gopeed", + Title: "Gopeed Test Script Runtime", + Version: "0.0.0", + DevMode: true, + }, map[string]any{}) } func waitForFileSizeAtLeast(t *testing.T, path string, size int64, timeout time.Duration) { @@ -786,3 +2005,106 @@ func waitForFileSizeAtLeast(t *testing.T, path string, size int64, timeout time. } t.Fatalf("timeout waiting for file %s size >= %d", path, size) } + +func waitForTaskTerminal(t *testing.T, downloader *Downloader, id string, timeout time.Duration) { + t.Helper() + doneCh := make(chan error, 1) + downloader.Listener(func(event *Event) { + if event.Task == nil || event.Task.ID != id { + return + } + if event.Key == EventKeyDone || event.Key == EventKeyError { + select { + case doneCh <- event.Err: + default: + } + } + }) + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + task := downloader.GetTask(id) + if task != nil { + switch task.Status { + case base.DownloadStatusDone: + return + case base.DownloadStatusError: + select { + case err := <-doneCh: + if err == nil { + t.Fatalf("task %s ended with error status", id) + } + t.Fatal(err) + default: + t.Fatalf("task %s ended with error status", id) + } + } + } + select { + case err := <-doneCh: + if err != nil { + t.Fatal(err) + } + return + case <-time.After(10 * time.Millisecond): + } + } + + select { + case err := <-doneCh: + if err != nil { + t.Fatal(err) + } + case <-time.After(timeout): + task := downloader.GetTask(id) + if task == nil { + t.Fatalf("timeout waiting for task %s: task not found", id) + } + t.Fatalf("timeout waiting for task %s: status=%s downloaded=%d total=%d", id, task.Status, task.Progress.Downloaded, task.Meta.Res.Size) + } +} + +func waitForTaskStatus(t *testing.T, downloader *Downloader, id string, status base.Status, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + task := downloader.GetTask(id) + if task != nil && task.Status == status { + return + } + time.Sleep(10 * time.Millisecond) + } + task := downloader.GetTask(id) + if task == nil { + t.Fatalf("timeout waiting for task %s status %s: task not found", id, status) + } + t.Fatalf("timeout waiting for task %s status %s: got %s", id, status, task.Status) +} + +func waitForDirEmpty(t *testing.T, path string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + entries, err := os.ReadDir(path) + if err == nil && len(entries) == 0 { + return + } + if errors.Is(err, os.ErrNotExist) { + return + } + time.Sleep(10 * time.Millisecond) + } + entries, err := os.ReadDir(path) + if err != nil && errors.Is(err, os.ErrNotExist) { + return + } + t.Fatalf("timeout waiting for directory %s to become empty, entries=%d err=%v", path, len(entries), err) +} + +func calcExtensionTestMD5(reader io.Reader) string { + hash := md5.New() + if _, err := io.Copy(hash, reader); err != nil { + return "" + } + return hex.EncodeToString(hash.Sum(nil)) +} diff --git a/pkg/download/extmock.go b/pkg/download/extmock.go deleted file mode 100644 index 99d6d680..00000000 --- a/pkg/download/extmock.go +++ /dev/null @@ -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 -} diff --git a/pkg/download/model.go b/pkg/download/model.go index a1485a6b..1bbce2e8 100644 --- a/pkg/download/model.go +++ b/pkg/download/model.go @@ -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 diff --git a/pkg/download/testdata/extensions/gblob/index.js b/pkg/download/testdata/extensions/gblob/index.js index f79c1d72..6404e823 100644 --- a/pkg/download/testdata/extensions/gblob/index.js +++ b/pkg/download/testdata/extensions/gblob/index.js @@ -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", diff --git a/pkg/download/testdata/extensions/gblob_recover/index.js b/pkg/download/testdata/extensions/gblob_recover/index.js index d5b22053..b6949449 100644 --- a/pkg/download/testdata/extensions/gblob_recover/index.js +++ b/pkg/download/testdata/extensions/gblob_recover/index.js @@ -3,15 +3,13 @@ gopeed.events.onResolve(async function (ctx) { return; } - const transform = new TransformStream(); - const writer = transform.writable.getWriter(); - const url = URL.createObjectURL(transform.writable); - - (async () => { - await writer.write(new TextEncoder().encode("stale\n")); - await new Promise((resolve) => setTimeout(resolve, 50)); - await writer.abort("expired"); - })(); + const url = URL.createObjectURL(new ReadableStream({ + async start(controller) { + controller.enqueue(new TextEncoder().encode("stale\n")); + await new Promise((resolve) => setTimeout(resolve, 50)); + controller.error(new Error("expired")); + }, + })); ctx.res = { name: "gblob-recover", diff --git a/pkg/download/testdata/extensions/gblob_restart/index.js b/pkg/download/testdata/extensions/gblob_restart/index.js new file mode 100644 index 00000000..8b987a1d --- /dev/null +++ b/pkg/download/testdata/extensions/gblob_restart/index.js @@ -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; + } +}); diff --git a/pkg/download/testdata/extensions/gblob_restart/manifest.json b/pkg/download/testdata/extensions/gblob_restart/manifest.json new file mode 100644 index 00000000..fe6f44fe --- /dev/null +++ b/pkg/download/testdata/extensions/gblob_restart/manifest.json @@ -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" + } + ] +} diff --git a/pkg/download/testdata/scripts/env_dump.sh b/pkg/download/testdata/scripts/env_dump.sh old mode 100644 new mode 100755 diff --git a/pkg/download/testdata/scripts/move.sh b/pkg/download/testdata/scripts/move.sh old mode 100644 new mode 100755 diff --git a/pkg/download/testdata/scripts/write_output1.sh b/pkg/download/testdata/scripts/write_output1.sh old mode 100644 new mode 100755 diff --git a/pkg/download/testdata/scripts/write_output2.sh b/pkg/download/testdata/scripts/write_output2.sh old mode 100644 new mode 100755 diff --git a/pkg/rest/model/server.go b/pkg/rest/model/server.go index f9bea8fd..4e586c1c 100644 --- a/pkg/rest/model/server.go +++ b/pkg/rest/model/server.go @@ -2,6 +2,7 @@ package model import ( "github.com/GopeedLab/gopeed/pkg/base" + enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" "io/fs" ) @@ -21,8 +22,10 @@ type StartConfig struct { WhiteDownloadDirs []string `json:"whiteDownloadDirs"` ApiToken string `json:"apiToken"` DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"` + WebViewRPCConfig *enginewebview.RPCConfig `json:"webViewRpcConfig,omitempty"` - ProductionMode bool + ProductionMode bool + WebViewProvider enginewebview.Provider `json:"-"` WebEnable bool WebFS fs.FS diff --git a/pkg/rest/server.go b/pkg/rest/server.go index a863a902..ae3d9387 100644 --- a/pkg/rest/server.go +++ b/pkg/rest/server.go @@ -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) diff --git a/pkg/rest/server_test.go b/pkg/rest/server_test.go index f68434e3..7768c1a5 100644 --- a/pkg/rest/server_test.go +++ b/pkg/rest/server_test.go @@ -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 diff --git a/ui/flutter/android/app/build.gradle b/ui/flutter/android/app/build.gradle index d5bc7f41..d247596a 100644 --- a/ui/flutter/android/app/build.gradle +++ b/ui/flutter/android/app/build.gradle @@ -24,7 +24,7 @@ if (flutterVersionName == null) { android { namespace 'com.gopeed.gopeed' - compileSdk 35 + compileSdk 36 ndkVersion flutter.ndkVersion compileOptions { diff --git a/ui/flutter/lib/api/api.dart b/ui/flutter/lib/api/api.dart index a38aab65..aea13d03 100644 --- a/ui/flutter/lib/api/api.dart +++ b/ui/flutter/lib/api/api.dart @@ -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) { diff --git a/ui/flutter/lib/api/model/create_task_batch.g.dart b/ui/flutter/lib/api/model/create_task_batch.g.dart index c9e24b0f..cf01d8fb 100644 --- a/ui/flutter/lib/api/model/create_task_batch.g.dart +++ b/ui/flutter/lib/api/model/create_task_batch.g.dart @@ -49,7 +49,7 @@ Map _$CreateTaskBatchItemToJson(CreateTaskBatchItem instance) { } } - writeNotNull('req', instance.req); - writeNotNull('opts', instance.opts); + writeNotNull('req', instance.req?.toJson()); + writeNotNull('opts', instance.opts?.toJson()); return val; } diff --git a/ui/flutter/lib/api/model/request.dart b/ui/flutter/lib/api/model/request.dart index 289f1e26..0473f0db 100644 --- a/ui/flutter/lib/api/model/request.dart +++ b/ui/flutter/lib/api/model/request.dart @@ -4,6 +4,7 @@ part 'request.g.dart'; @JsonSerializable(explicitToJson: true) class Request { + String? rawUrl; String url; Object? extra; Map? labels; @@ -11,6 +12,7 @@ class Request { bool skipVerifyCert; Request({ + this.rawUrl, required this.url, this.extra, this.labels, diff --git a/ui/flutter/lib/api/model/request.g.dart b/ui/flutter/lib/api/model/request.g.dart index bd2638df..f4e849f5 100644 --- a/ui/flutter/lib/api/model/request.g.dart +++ b/ui/flutter/lib/api/model/request.g.dart @@ -7,6 +7,7 @@ part of 'request.dart'; // ************************************************************************** Request _$RequestFromJson(Map json) => Request( + rawUrl: json['rawUrl'] as String?, url: json['url'] as String, extra: json['extra'], labels: (json['labels'] as Map?)?.map( @@ -20,6 +21,7 @@ Request _$RequestFromJson(Map json) => Request( Map _$RequestToJson(Request instance) { final val = { + 'rawUrl': instance.rawUrl, 'url': instance.url, }; diff --git a/ui/flutter/lib/api/model/task.dart b/ui/flutter/lib/api/model/task.dart index 830d772d..2aea7e8c 100644 --- a/ui/flutter/lib/api/model/task.dart +++ b/ui/flutter/lib/api/model/task.dart @@ -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 { diff --git a/ui/flutter/lib/api/model/task.g.dart b/ui/flutter/lib/api/model/task.g.dart index b2889e73..4db11e3a 100644 --- a/ui/flutter/lib/api/model/task.g.dart +++ b/ui/flutter/lib/api/model/task.g.dart @@ -52,6 +52,7 @@ const _$ProtocolEnumMap = { Protocol.http: 'http', Protocol.bt: 'bt', Protocol.ed2k: 'ed2k', + Protocol.gblob: 'gblob', }; Progress _$ProgressFromJson(Map json) => Progress( diff --git a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart index a968ca7b..2bafbd26 100644 --- a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart +++ b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'dart:ui'; import 'package:app_links/app_links.dart'; -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:get/get.dart'; @@ -17,13 +16,11 @@ import 'package:uri_to_file/uri_to_file.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; - import '../../../../api/api.dart'; import '../../../../api/model/create_task.dart'; import '../../../../api/model/downloader_config.dart'; import '../../../../api/model/install_extension.dart'; import '../../../../api/model/request.dart'; -import '../../../../api/model/result.dart'; import '../../../../core/common/start_config.dart'; import '../../../../core/libgopeed_boot.dart'; import '../../../../database/database.dart'; @@ -37,7 +34,8 @@ import '../../../../util/package_info.dart'; import '../../../../util/updater.dart'; import '../../../../util/util.dart'; import '../../../routes/app_pages.dart'; -import '../../../rpc/rpc.dart'; +import '../../../rpc/host_rpc_service.dart'; +import '../../../rpc/webview_rpc_service.dart'; import '../../redirect/views/redirect_view.dart'; import '../../../services/notification_service.dart'; @@ -98,7 +96,7 @@ class AppController extends GetxController with WindowListener, TrayListener { _initWindows().onError((error, stackTrace) => logger.w("initWindows error", error, stackTrace)); - + if (Util.isDesktop()) { Get.put(NotificationService()); } @@ -126,6 +124,8 @@ class AppController extends GetxController with WindowListener, TrayListener { void onClose() { _linkSubscription?.cancel(); trayManager.removeListener(this); + HostRpcService.instance.stop(); + WebViewRpcService.instance.stop(); LibgopeedBoot.instance.stop(); } @@ -330,54 +330,33 @@ class AppController extends GetxController with WindowListener, TrayListener { return; } try { - await startRpcServer({ - "/create": (ctx) async { - final meta = - ctx.request.headers["X-Gopeed-Host-Meta"]?.firstOrNull ?? "{}"; - final jsonMeta = jsonDecode(meta); - final silent = jsonMeta['silent'] as bool? ?? false; - final params = await ctx.readText(); - final createTaskParams = CreateTask.fromJson(_decodeParams(params)); + await HostRpcService.instance.start( + onCreate: (createTaskParams, silent) async { if (!silent || pendingUpdateTask.value != null) { await windowManager.show(); _handleToCreate0(createTaskParams); - } else { - try { - await createTask(createTaskParams); - } catch (e) { - logger.w( - "create task from extension fail", e, StackTrace.current); - } + return; } - }, - "/forward": (ctx) async { try { - final body = await ctx.readJSON(); - final method = (body['method'] as String?)?.toUpperCase() ?? 'GET'; - final path = (body['path'] as String?) ?? "/"; - final data = body['data']; - final query = body['query'] as Map?; - - // Forward request to gopeed REST API - final response = await forward( - path, - method: method, - data: data, - queryParameters: query, - ); - - // Return raw response - await ctx.writeJSON(response.data); + await createTask(createTaskParams); } catch (e) { - if (e is DioException && e.response != null) { - // Return API error response - await ctx.writeJSON(e.response!.data); - } else { - await ctx.writeJSON(Result(code: 1, msg: e.toString()).toJson()); - } + logger.w("create task from extension fail", e, StackTrace.current); } }, - }); + onForward: ({ + required String path, + required String method, + data, + Map? query, + }) { + return forward( + path, + method: method, + data: data, + queryParameters: query, + ); + }, + ); } catch (e) { logger.w("start rpc server fail", e, StackTrace.current); } diff --git a/ui/flutter/lib/app/modules/app/views/app_view.dart b/ui/flutter/lib/app/modules/app/views/app_view.dart index 80a1e8ec..77661f72 100644 --- a/ui/flutter/lib/app/modules/app/views/app_view.dart +++ b/ui/flutter/lib/app/modules/app/views/app_view.dart @@ -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 { } // 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(builder: (_) => child!), + ]; + if (WebViewRpcService.instance.supported) { + entries.add(OverlayEntry(builder: (_) => const WebViewRpcOverlay())); + } + return Overlay(initialEntries: entries); }, ), ); diff --git a/ui/flutter/lib/app/modules/task/views/task_view.dart b/ui/flutter/lib/app/modules/task/views/task_view.dart index a3ccb052..ad586ace 100644 --- a/ui/flutter/lib/app/modules/task/views/task_view.dart +++ b/ui/flutter/lib/app/modules/task/views/task_view.dart @@ -17,6 +17,14 @@ import 'task_downloading_view.dart'; class TaskView extends GetView { 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 { 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), diff --git a/ui/flutter/lib/app/rpc/host_rpc_service.dart b/ui/flutter/lib/app/rpc/host_rpc_service.dart new file mode 100644 index 00000000..fc8ada44 --- /dev/null +++ b/ui/flutter/lib/app/rpc/host_rpc_service.dart @@ -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 Function( + CreateTask createTask, + bool silent, +); + +typedef HostForwardHandler = Future Function({ + required String path, + required String method, + dynamic data, + Map? query, +}); + +class HostRpcService { + HostRpcService._(); + + static final HostRpcService instance = HostRpcService._(); + + RpcServerHandle? _server; + + Future 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?; + final response = await onForward( + path: path, + method: method, + data: data, + query: query, + ); + if (response is Map) { + 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 stop() async { + final server = _server; + _server = null; + if (server != null) { + await server.close(); + } + } + + Map _decodeParams(String params) { + final safeParams = params.replaceAll('"', '').replaceAll(' ', '+'); + final paramsJson = + String.fromCharCodes(base64Decode(base64.normalize(safeParams))); + return jsonDecode(paramsJson); + } +} diff --git a/ui/flutter/lib/app/rpc/server.dart b/ui/flutter/lib/app/rpc/server.dart new file mode 100644 index 00000000..8a5a6195 --- /dev/null +++ b/ui/flutter/lib/app/rpc/server.dart @@ -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> readJSON() async { + if (_bodyCache == null) { + final content = []; + await for (final data in request) { + content.addAll(data); + } + _bodyCache = String.fromCharCodes(content); + } + + if (_bodyCache!.isEmpty) { + return {}; + } + + return jsonDecode(_bodyCache!) as Map; + } + + Future readText() async { + if (_bodyCache == null) { + final content = []; + await for (final data in request) { + content.addAll(data); + } + _bodyCache = String.fromCharCodes(content); + } + + return _bodyCache ?? ''; + } + + Future writeJSON(Map data) async { + response.headers.contentType = ContentType.json; + response.write(jsonEncode(data)); + } + + Future writeError(String message, [int statusCode = 500]) async { + response.statusCode = statusCode; + await writeJSON({'error': message}); + } +} + +typedef RouteHandler = Future Function(RpcContext ctx); + +class RouteRegistry { + final Map _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 Function()? _cleanup; + + RpcServerHandle({ + required this.binding, + required this.server, + Future Function()? cleanup, + }) : _cleanup = cleanup; + + Future close({bool force = true}) async { + await server.close(force: force); + if (_cleanup != null) { + await _cleanup!(); + } + } +} + +Future 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 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 startRpcServer({ + RpcBinding? binding, + Map? routes, +}) async { + final rpcBinding = binding ?? await defaultHostRpcBinding(); + Future 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, + ); +} diff --git a/ui/flutter/lib/app/rpc/webview_rpc_overlay.dart b/ui/flutter/lib/app/rpc/webview_rpc_overlay.dart new file mode 100644 index 00000000..f3943d71 --- /dev/null +++ b/ui/flutter/lib/app/rpc/webview_rpc_overlay.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +import 'webview_rpc_service.dart'; + +class WebViewRpcOverlay extends StatefulWidget { + const WebViewRpcOverlay({super.key}); + + @override + State createState() => _WebViewRpcOverlayState(); +} + +class _WebViewRpcOverlayState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + WebViewRpcService.instance.markOverlayReady(); + }); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + 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), + ], + ), + ), + ), + ), + ); + } +} diff --git a/ui/flutter/lib/app/rpc/webview_rpc_service.dart b/ui/flutter/lib/app/rpc/webview_rpc_service.dart new file mode 100644 index 00000000..486ddae8 --- /dev/null +++ b/ui/flutter/lib/app/rpc/webview_rpc_service.dart @@ -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 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> pages = + ValueNotifier>([]); + + final Map _pagesById = {}; + final Completer _overlayReady = Completer(); + 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 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 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 _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() ?? + const {}; + 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 _dispatch(String method, Map 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> _openPage(Map 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 params) { + final page = _pagesById[_pageId(params)]; + if (page == null) { + throw WebViewRpcException( + code: 'PAGE_NOT_FOUND', + message: 'page not found', + ); + } + return page; + } + + String _pageId(Map params) => _string(params, 'pageId'); + + String _string(Map 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 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 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? _list(Map params, String key) { + final value = params[key]; + if (value == null) { + return null; + } + if (value is List) { + return value; + } + throw WebViewRpcException( + code: 'INVALID_REQUEST', + message: 'invalid "$key"', + ); + } + + Map _cookie(Map params) { + final value = params['cookie']; + if (value is Map) { + return value; + } + if (value is Map) { + return value.cast(); + } + throw WebViewRpcException( + code: 'INVALID_REQUEST', + message: 'missing or invalid "cookie"', + ); + } + + Future _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 _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 _initScripts = []; + final Map> _pendingExecutions = + >{}; + + final Completer _ready = Completer(); + HeadlessInAppWebView? _headlessWebView; + InAppWebViewController? _controller; + Completer? _navigation; + String _currentUrl = ''; + int _executeSeq = 0; + bool _disposed = false; + + Future 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 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 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([ + _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 execute(String expression, List args) async { + final controller = await _controllerOrThrow(); + final requestId = 'exec-$pageId-${++_executeSeq}'; + final completer = Completer(); + _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 _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.delayed(const Duration(milliseconds: 50)); + } + } + + Future>> getCookies() async { + if (_currentUrl.isEmpty) { + return const []; + } + final cookies = await cookieManager.getCookies(url: WebUri(_currentUrl)); + return cookies.map((cookie) { + return { + 'name': cookie.name, + 'value': cookie.value, + 'domain': cookie.domain ?? '', + 'path': cookie.path ?? '/', + 'secure': cookie.isSecure, + 'httpOnly': cookie.isHttpOnly, + }; + }).toList(growable: false); + } + + Future setCookie(Map 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 deleteCookie(Map 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 clearCookies() async { + await cookieManager.deleteAllCookies(); + } + + Future 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 _ensureNavigationCompleter() { + return _navigation ??= Completer(); + } + + Widget buildWebView() { + return InAppWebView( + key: ValueKey('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 _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 _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 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 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(); + 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; +} diff --git a/ui/flutter/lib/app/views/buid_task_list_view.dart b/ui/flutter/lib/app/views/buid_task_list_view.dart index 8c6ed5a3..e0c18e20 100644 --- a/ui/flutter/lib/app/views/buid_task_list_view.dart +++ b/ui/flutter/lib/app/views/buid_task_list_view.dart @@ -119,7 +119,10 @@ class BuildTaskListView extends GetView { } Future 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 = >[]; diff --git a/ui/flutter/lib/core/common/start_config.dart b/ui/flutter/lib/core/common/start_config.dart index 26bf481d..f27d7477 100644 --- a/ui/flutter/lib/core/common/start_config.dart +++ b/ui/flutter/lib/core/common/start_config.dart @@ -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 json) => + _$WebViewRpcConfigFromJson(json); + + Map 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(); diff --git a/ui/flutter/lib/core/common/start_config.g.dart b/ui/flutter/lib/core/common/start_config.g.dart index 85e67b85..d0202434 100644 --- a/ui/flutter/lib/core/common/start_config.g.dart +++ b/ui/flutter/lib/core/common/start_config.g.dart @@ -6,13 +6,30 @@ part of 'start_config.dart'; // JsonSerializableGenerator // ************************************************************************** +WebViewRpcConfig _$WebViewRpcConfigFromJson(Map json) => + WebViewRpcConfig() + ..network = json['network'] as String + ..address = json['address'] as String + ..token = json['token'] as String?; + +Map _$WebViewRpcConfigToJson(WebViewRpcConfig instance) => + { + 'network': instance.network, + 'address': instance.address, + 'token': instance.token, + }; + StartConfig _$StartConfigFromJson(Map 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); Map _$StartConfigToJson(StartConfig instance) => { @@ -22,4 +39,5 @@ Map _$StartConfigToJson(StartConfig instance) => 'storageDir': instance.storageDir, 'refreshInterval': instance.refreshInterval, 'apiToken': instance.apiToken, + 'webViewRpcConfig': instance.webViewRpcConfig, }; diff --git a/ui/flutter/lib/main.dart b/ui/flutter/lib/main.dart index ac1bf1e4..17f6830d 100644 --- a/ui/flutter/lib/main.dart +++ b/ui/flutter/lib/main.dart @@ -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 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) { diff --git a/ui/flutter/macos/Podfile.lock b/ui/flutter/macos/Podfile.lock index 37615f88..f43c948e 100644 --- a/ui/flutter/macos/Podfile.lock +++ b/ui/flutter/macos/Podfile.lock @@ -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 diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock index b30dfe0e..bc1d1d28 100644 --- a/ui/flutter/pubspec.lock +++ b/ui/flutter/pubspec.lock @@ -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: diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index 7c1982e0..94f11644 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -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: