From 21702d5d462b8aa688dbba7d17e6759972421f3e Mon Sep 17 00:00:00 2001 From: Levi Date: Wed, 16 Sep 2026 23:17:43 +0800 Subject: [PATCH] feat(webview): add proxy support and per-extension profile lifecycle (#1517) --- .gitattributes | 2 + .github/workflows/test.yml | 3 +- go.mod | 4 +- go.sum | 4 +- internal/webview/README.md | 62 ++++ .../webview/goprovider/mainthread_linux.go | 47 +++ .../webview/goprovider/mainthread_other.go | 2 +- internal/webview/goprovider/provider.go | 32 +- .../goprovider/provider_integration_test.go | 2 + internal/webview/integrationtest/contract.go | 39 +- internal/webview/integrationtest/profiles.go | 247 +++++++++++++ internal/webview/proxy/server.go | 341 ++++++++++++++++++ internal/webview/proxy/server_test.go | 315 ++++++++++++++++ internal/webview/rpcprovider/README.md | 6 + internal/webview/rpcprovider/provider.go | 12 +- .../rpcprovider/provider_integration_test.go | 5 + internal/webview/rpcprovider/provider_test.go | 26 ++ pkg/download/downloader.go | 14 +- pkg/download/engine/webview/rpc.go | 12 + pkg/download/engine/webview/runtime.go | 9 + pkg/download/engine/webview/runtime_test.go | 12 + pkg/download/extension.go | 11 +- pkg/download/extension_engine.go | 2 +- pkg/download/extension_engine_session.go | 4 +- pkg/download/extension_webview_config.go | 183 ++++++++++ pkg/download/extension_webview_config_test.go | 51 +++ pkg/download/extension_webview_remove_test.go | 189 ++++++++++ ui/flutter/analysis_options.yaml | 4 + .../lib/app/rpc/webview_rpc_service.dart | 29 +- ui/flutter/macos/Runner/Info.plist | 5 + ui/flutter/pubspec.lock | 53 +-- ui/flutter/pubspec.yaml | 26 ++ 32 files changed, 1708 insertions(+), 45 deletions(-) create mode 100644 .gitattributes create mode 100644 internal/webview/README.md create mode 100644 internal/webview/goprovider/mainthread_linux.go create mode 100644 internal/webview/integrationtest/profiles.go create mode 100644 internal/webview/proxy/server.go create mode 100644 internal/webview/proxy/server_test.go create mode 100644 pkg/download/extension_webview_config.go create mode 100644 pkg/download/extension_webview_config_test.go create mode 100644 pkg/download/extension_webview_remove_test.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..437f6d74 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Keep upstream plugin sources recognizable and collapsed in GitHub reviews. +ui/flutter/third_party/** linguist-vendored whitespace=-blank-at-eol,-blank-at-eof diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a7138158..745fe371 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -91,7 +91,8 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} - name: Test webview if: runner.os == 'Windows' - run: go test -tags webview -v -count=1 -timeout 300s ./internal/webview/goprovider/ + # Repeat close/reopen coverage to catch browser shutdown races. + run: go test -tags webview -v -count=3 -timeout 300s ./internal/webview/goprovider/ env: CGO_ENABLED: 1 - name: Test webview diff --git a/go.mod b/go.mod index f3575db8..b01d1f1e 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +4,14 @@ go 1.25.4 require ( codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0 - github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5 + github.com/GopeedLab/webview_go v0.0.0-20260915010614-2567e492369a 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 github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc github.com/go-git/go-git/v5 v5.8.1 + github.com/google/jsonschema-go v0.4.3 github.com/gorilla/handlers v1.5.1 github.com/gorilla/mux v1.8.0 github.com/imroc/req/v3 v3.52.2 @@ -40,7 +41,6 @@ require ( github.com/felixge/fgprof v0.9.5 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/jsonschema-go v0.4.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect diff --git a/go.sum b/go.sum index b1f117c1..98c4c2eb 100644 --- a/go.sum +++ b/go.sum @@ -25,8 +25,8 @@ filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmG filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5 h1:86LVTaNDM/j/uTAD2HedkXNO9LIQlt642VAdbU/0D/M= -github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0= +github.com/GopeedLab/webview_go v0.0.0-20260915010614-2567e492369a h1:Vzz+8aMVX9ZPPpkxgxa7mCorrAgdXCZL5EunOpOBvao= +github.com/GopeedLab/webview_go v0.0.0-20260915010614-2567e492369a/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= diff --git a/internal/webview/README.md b/internal/webview/README.md new file mode 100644 index 00000000..bd46c554 --- /dev/null +++ b/internal/webview/README.md @@ -0,0 +1,62 @@ +# Extension browser host configuration + +The downloader wraps each provider with the extension's stable identity before +constructing the runtime. `OpenOptions.ProfileID`, `DataPath`, and `ProxyURL` are +host-only fields. `parseOpenOptions` never reads them from extension JavaScript. +The extension API is unchanged. The Flutter host uses the fork's public +`WebViewProfile`, `InAppWebViewSettings.profileId`, and profile-scoped +`CookieManager` APIs; the library has no Gopeed-specific method-channel bridge. +Only the Gopeed extension JavaScript boundary keeps these options host-only. + +Profiles are persistent and shared across invocations/pages of the same extension. +Different identities use different native stores. Go providers use a SHA-256-based +path under `/webview`; Apple RPC hosts use a stable UUID data store; +Android uses a named WebView profile. Cookie operations select that same store, +including `clearCookies`. Closing an execution closes its pages, not its profile. +Uninstall closes the extension's pages, prevents its old runtimes from opening +new pages, and removes its native profile before deleting the extension record. +Cleanup failures abort uninstall and can be retried. Reinstallation starts with +empty browser data; an ordinary extension update keeps the existing profile. +Windows/Linux delete the profile directory; Apple removes the named WebKit store. +Android cannot delete a profile already loaded by the current process: when +DELETE_BROWSING_DATA is supported it clears all site data immediately, and records +an empty-profile deletion in SharedPreferences for the next process start. A +reinstallation may reuse that cleared profile and cancels the pending deletion. +If the full-data clearing API is unavailable, uninstall fails with a restart +instruction; the pending profile is deleted at startup before it can be loaded. +No removal operation is exposed through extension JavaScript. + +Existing shared browser data is not copied into every extension's new profile. + +The downloader lazily owns one loopback forward proxy with HTTP and SOCKS5 +listeners. Windows/Linux/Android use HTTP; Apple uses SOCKS5 because its HTTP +CONNECT configuration may bypass plain HTTP. The forwarder reads the current +Gopeed proxy config for each new request/tunnel and supports direct, system, +HTTP(S) upstream proxies with Basic authentication, and authenticated SOCKS5. +Credentials remain in Go and are never sent over the WebView RPC. + +Changes affect new requests/connections. Existing CONNECT/SOCKS tunnels keep their +route until closed; opening another page in the same profile does not reset it. +SOCKS carries host/port rather than a full URL: system proxy/PAC selection for +Apple is host-based, with port 80 treated as HTTP and other ports as HTTPS. +Path-dependent PAC rules cannot be reproduced from a SOCKS connection. + +Apple requires macOS 14 / iOS 17. Android checks MULTI_PROFILE and PROXY_OVERRIDE. +Unsupported hosts fail explicitly instead of silently sharing browser storage or +ignoring the proxy. + +## Validation + +- `go test -race ./internal/webview/proxy ./pkg/download/engine/webview` +- `go test ./pkg/download -run 'TestWebViewProfileBound|TestDownloader_ExtensionRuntimeWebView'` +- Windows/Linux: `go test -tags webview -v ./internal/webview/goprovider` (Linux: run under `xvfb-run -a`). +- macOS: start the normal Flutter app, then run `go test -tags webview -v ./internal/webview/rpcprovider` with its socket address. See `rpcprovider/README.md`. +- In webview_go: `go test -tags webview_integration -v -count=1 .` (Linux: run under `xvfb-run -a`). + +The shared provider contract covers HTTP/HTTPS proxy routing, concurrent pages, +HttpOnly cookies, LocalStorage, IndexedDB, profile-scoped clearing, reopening, +and removal without affecting another profile. HTTPS routing is checked with a +rejecting upstream tunnel; no test-only certificate bypass is added to the app. +Go proxy unit tests separately cover successful TLS forwarding and authentication. +These cases run in the existing `test.yml` WebView jobs; existing mobile build jobs +compile the Android/iOS bridge. No separate Flutter test application is required. diff --git a/internal/webview/goprovider/mainthread_linux.go b/internal/webview/goprovider/mainthread_linux.go new file mode 100644 index 00000000..3e0a09da --- /dev/null +++ b/internal/webview/goprovider/mainthread_linux.go @@ -0,0 +1,47 @@ +//go:build linux && cgo + +package goprovider + +/* +#cgo pkg-config: gtk+-3.0 +#include +static void pumpGTK(void) { + // Bound each batch so Go creation/removal requests cannot be starved. + for (int i = 0; i < 64 && g_main_context_pending(NULL); ++i) + g_main_context_iteration(NULL, FALSE); +} +*/ +import "C" + +import ( + "runtime" + "sync" + "time" +) + +var gtkThreadOnce sync.Once +var gtkThreadTasks = make(chan func()) + +func RunMainThreadLoop(run func() int) int { return run() } + +func postMainThreadTask(task func()) bool { + gtkThreadOnce.Do(func() { + go func() { + runtime.LockOSThread() + // WebKit must always use its original UI thread, including after + // the last page closes. A page cannot stop this shared event loop. + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + select { + case task := <-gtkThreadTasks: + task() + case <-ticker.C: + } + C.pumpGTK() + } + }() + }) + gtkThreadTasks <- task + return true +} diff --git a/internal/webview/goprovider/mainthread_other.go b/internal/webview/goprovider/mainthread_other.go index aa3ee4a1..45ad0255 100644 --- a/internal/webview/goprovider/mainthread_other.go +++ b/internal/webview/goprovider/mainthread_other.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin && (!linux || !cgo) package goprovider diff --git a/internal/webview/goprovider/provider.go b/internal/webview/goprovider/provider.go index a81c20df..c9d06a5e 100644 --- a/internal/webview/goprovider/provider.go +++ b/internal/webview/goprovider/provider.go @@ -105,7 +105,15 @@ func (p *pageWrapper) start() error { defer runtimepkg.UnlockOSThread() var w webview.WebView - if p.opts.Headless { + if p.opts.DataPath != "" { + var err error + w, err = webview.NewWithOptions(webview.Options{Debug: p.opts.Debug, Headless: p.opts.Headless, DataPath: p.opts.DataPath, ProxyURL: p.opts.ProxyURL}) + if err != nil { + p.ready <- err + close(p.done) + return + } + } else if p.opts.Headless { w = webview.NewHeadless(p.opts.Debug) } else { w = webview.New(p.opts.Debug) @@ -127,12 +135,18 @@ func (p *pageWrapper) start() error { if err := w.Bind(p.callbackName, func(payload string) error { return p.handleCallback(payload) }); err != nil { + w.Destroy() p.ready <- err + close(p.done) return } applyWindowOptions(w, p.opts) w.SetHtml(buildBootstrapHTML(p.callbackName, p.readyID)) + if runtimepkg.GOOS == "linux" { + // All GTK pages share the process-wide event loop below. + return + } w.Run() w.Destroy() close(p.done) @@ -318,7 +332,12 @@ func (p *pageWrapper) Close() error { // the quit message lands on the correct queue. terminateDone := make(chan struct{}) w.Dispatch(func() { - w.Terminate() + if runtimepkg.GOOS == "linux" { + w.Destroy() + close(p.done) + } else { + w.Terminate() + } close(terminateDone) }) <-terminateDone @@ -610,3 +629,12 @@ func firstNonEmpty(values ...string) string { } return "" } + +func (p *Provider) RemoveProfile(profileID, dataPath string) error { + done := make(chan error, 1) + run := func() { done <- webview.RemoveProfile(dataPath) } + if !postMainThreadTask(run) { + run() + } + return <-done +} diff --git a/internal/webview/goprovider/provider_integration_test.go b/internal/webview/goprovider/provider_integration_test.go index 92f226e7..e6d9e63e 100644 --- a/internal/webview/goprovider/provider_integration_test.go +++ b/internal/webview/goprovider/provider_integration_test.go @@ -13,3 +13,5 @@ func TestProviderContract(t *testing.T) { CookieDomainMode: integrationtest.CookieDomainModeRequired, }) } + +func TestProviderProfiles(t *testing.T) { integrationtest.RunProfileContract(t, New()) } diff --git a/internal/webview/integrationtest/contract.go b/internal/webview/integrationtest/contract.go index 1a508e35..364fe5c2 100644 --- a/internal/webview/integrationtest/contract.go +++ b/internal/webview/integrationtest/contract.go @@ -1,10 +1,14 @@ package integrationtest import ( + "crypto/sha256" "fmt" + webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy" "net/http" "net/http/httptest" "net/url" + "path/filepath" + "runtime" "strings" "testing" "time" @@ -219,7 +223,19 @@ func openTestPage(t *testing.T, provider enginewebview.Provider, opts enginewebv })) t.Cleanup(server.Close) - runtime := enginewebview.NewRuntime(provider, true) + proxy, err := webviewproxy.New(nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { proxy.Close() }) + path := filepath.Join(t.TempDir(), "profile") + sum := sha256.Sum256([]byte(path)) + profileID := fmt.Sprintf("%x-%x-%x-%x-%x", sum[:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16]) + proxyURL := proxy.URL() + if runtime.GOOS == "darwin" { + proxyURL = proxy.SOCKSURL() + } + runtime := enginewebview.NewRuntime(&configuredOpener{provider: provider, profileID: profileID, dataPath: path, proxyURL: proxyURL}, true) page, err := runtime.Open(map[string]any{ "headless": opts.Headless, "debug": opts.Debug, @@ -232,7 +248,14 @@ func openTestPage(t *testing.T, provider enginewebview.Provider, opts enginewebv t.Fatalf("failed to open webview: %v", err) } t.Cleanup(func() { - _ = runtime.Close() + if err := runtime.Close(); err != nil { + t.Errorf("close contract page: %v", err) + } + if remover, ok := provider.(enginewebview.ProfileRemover); ok { + if err := remover.RemoveProfile(profileID, path); err != nil { + t.Errorf("remove contract profile: %v", err) + } + } }) return page, server, server.URL @@ -380,3 +403,15 @@ func hasCookie(cookies []enginewebview.Cookie, name string, value string) bool { } return false } + +type configuredOpener struct { + provider enginewebview.Provider + profileID, dataPath, proxyURL string +} + +func (o *configuredOpener) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) { + opts.ProfileID = o.profileID + opts.DataPath = o.dataPath + opts.ProxyURL = o.proxyURL + return o.provider.Open(opts) +} diff --git a/internal/webview/integrationtest/profiles.go b/internal/webview/integrationtest/profiles.go new file mode 100644 index 00000000..72d297ba --- /dev/null +++ b/internal/webview/integrationtest/profiles.go @@ -0,0 +1,247 @@ +package integrationtest + +import ( + "crypto/sha256" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "reflect" + "runtime" + "sync/atomic" + "testing" + "time" + + webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy" + webview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" +) + +// RunProfileContract exercises the same host API through both native and RPC +// providers. It never bypasses the host bridge with direct Flutter plugin calls. +func RunProfileContract(t *testing.T, provider webview.Provider) { + t.Helper() + waitForProviderAvailable(t, provider, defaultAvailabilityTimeout) + remover, ok := provider.(webview.ProfileRemover) + if !ok { + t.Fatal("provider does not implement host profile removal") + } + proxyURL, secureHits := profileProxy(t) + a := newContractProfile(t, provider, remover, proxyURL) + b := newContractProfile(t, provider, remover, proxyURL) + first := a.open(t) + assertProfileValue(t, first, `async () => { localStorage.setItem('owner','a'); return 'a'; }`, "a") + assertProfileValue(t, first, profileDatabaseScript(true), "saved") + if err := first.SetCookie(webview.Cookie{Name: "session", Value: "a", Domain: "profiles.invalid", Path: "/", HTTPOnly: true, Expires: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + second := b.open(t) + assertProfileValue(t, second, `() => localStorage.getItem('owner')`, nil) + assertProfileValue(t, second, profileDatabaseScript(false), nil) + assertProfileCookies(t, second, "", "new profile B") + assertProfileValue(t, second, `() => { localStorage.setItem('owner','b'); return 'b'; }`, "b") + if err := second.SetCookie(webview.Cookie{Name: "session", Value: "b", Domain: "profiles.invalid", Path: "/", HTTPOnly: true}); err != nil { + t.Fatal(err) + } + shared := a.open(t) + assertProfileValue(t, shared, `() => localStorage.getItem('owner')`, "a") + assertProfileValue(t, shared, profileDatabaseScript(false), "saved") + assertProfileCookies(t, shared, "a", "shared profile A") + storedCookies, err := shared.GetCookies() + if err != nil || len(storedCookies) != 1 || storedCookies[0].Expires.Before(time.Now()) { + t.Fatalf("persistent cookie lost its expiration: %#v, error %v", storedCookies, err) + } + assertProfileValue(t, shared, `() => document.cookie`, "") // HttpOnly stays hidden. + if err := second.ClearCookies(); err != nil { + t.Fatal(err) + } + assertProfileCookies(t, first, "a", "clearing B leaves A intact") + if err := second.SetCookie(webview.Cookie{Name: "session", Value: "b", Domain: "profiles.invalid", Path: "/", HTTPOnly: true}); err != nil { + t.Fatal(err) + } + a.close(t) + reopened := a.open(t) + assertProfileValue(t, reopened, `() => localStorage.getItem('owner')`, "a") + assertProfileValue(t, reopened, profileDatabaseScript(false), "saved") + assertProfileCookies(t, reopened, "a", "reopened profile A") + // Include a session cookie immediately before deletion to catch stale + // in-process CookieStore caches when the same profile is recreated. + if err := reopened.SetCookie(webview.Cookie{Name: "session", Value: "remove-me", Domain: "profiles.invalid", Path: "/", HTTPOnly: true}); err != nil { + t.Fatal(err) + } + a.close(t) + a.remove(t) + a.remove(t) // Retrying a successful uninstall is harmless. + reinstalled := a.open(t) + assertProfileValue(t, reinstalled, `() => localStorage.getItem('owner')`, nil) + assertProfileValue(t, reinstalled, profileDatabaseScript(false), nil) + assertProfileCookies(t, reinstalled, "", "reinstalled profile A") + assertProfileValue(t, second, `() => localStorage.getItem('owner')`, "b") + assertProfileCookies(t, second, "b", "B after removing A") + // Refuse the HTTPS tunnel deliberately. Seeing it at our proxy proves HTTPS + // routing without adding a certificate-validation bypass to the test host. + // WebView2 may commit its built-in error page and report navigation as + // complete. The invariant here is that the HTTPS tunnel reaches the proxy. + _ = reinstalled.Goto("https://profiles.invalid/", webview.GotoOptions{TimeoutMS: 5000}) + deadline := time.Now().Add(5 * time.Second) + for secureHits.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if secureHits.Load() == 0 { + t.Fatal("HTTPS bypassed the host proxy") + } +} + +type contractProfile struct { + provider webview.Provider + remover webview.ProfileRemover + options webview.OpenOptions + pages []webview.Page +} + +func newContractProfile(t *testing.T, provider webview.Provider, remover webview.ProfileRemover, proxyURL string) *contractProfile { + t.Helper() + path := filepath.Join(t.TempDir(), "profile") + hash := sha256.Sum256([]byte(path)) + id := fmt.Sprintf("%x-%x-%x-%x-%x", hash[:4], hash[4:6], hash[6:8], hash[8:10], hash[10:16]) + p := &contractProfile{provider: provider, remover: remover, options: webview.OpenOptions{ProfileID: id, DataPath: path, ProxyURL: proxyURL, Headless: true, Title: "Gopeed profile contract"}} + t.Cleanup(func() { p.close(t); p.remove(t) }) + return p +} +func (p *contractProfile) open(t *testing.T) webview.Page { + t.Helper() + page, err := p.provider.Open(p.options) + if err != nil { + t.Fatal(err) + } + p.pages = append(p.pages, page) + if err := page.Goto("http://profiles.invalid/", webview.GotoOptions{TimeoutMS: 10000}); err != nil { + t.Fatal(err) + } + return page +} +func (p *contractProfile) close(t *testing.T) { + t.Helper() + for _, page := range p.pages { + if err := page.Close(); err != nil { + t.Errorf("close profile page: %v", err) + } + } + p.pages = nil +} +func (p *contractProfile) remove(t *testing.T) { + t.Helper() + if err := p.remover.RemoveProfile(p.options.ProfileID, p.options.DataPath); err != nil { + t.Errorf("remove profile: %v", err) + } +} +func assertProfileValue(t *testing.T, page webview.Page, script string, want any) { + t.Helper() + got, err := page.Execute(script) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("script %s: got %#v, want %#v", script, got, want) + } +} +func assertProfileCookies(t *testing.T, page webview.Page, want, stage string) { + t.Helper() + // Native cookie managers propagate updates across browser processes + // asynchronously. Wait for the expected state rather than assuming the + // first IPC read already includes the preceding write. + deadline := time.Now().Add(5 * time.Second) + for { + cookies, err := page.GetCookies() + if err != nil { + t.Fatalf("%s: %v", stage, err) + } + matches := len(cookies) == 0 && want == "" || len(cookies) == 1 && want != "" && cookies[0].Name == "session" && cookies[0].Value == want && cookies[0].HTTPOnly + if matches { + return + } + if time.Now().After(deadline) { + t.Fatalf("%s: cookies %#v, want %q", stage, cookies, want) + } + time.Sleep(20 * time.Millisecond) + } +} +func profileDatabaseScript(write bool) string { + operation := "store.get('owner')" + if write { + operation = "store.put('saved','owner')" + } + return fmt.Sprintf(`async () => await new Promise((resolve,reject)=>{ + const request=indexedDB.open('profile-contract',1); + request.onupgradeneeded=()=>request.result.createObjectStore('state'); + request.onerror=()=>reject(String(request.error)); + request.onsuccess=()=>{ + const db=request.result, tx=db.transaction('state','readwrite'), store=tx.objectStore('state'); + const op=%s; let value; + op.onsuccess=()=>value=%s; + tx.oncomplete=()=>{db.close();resolve(value===undefined?null:value)}; + tx.onerror=()=>{db.close();reject(String(tx.error))}; + }; +})`, operation, map[bool]string{true: "'saved'", false: "op.result"}[write]) +} + +func profileProxy(t *testing.T) (string, *atomic.Int32) { + t.Helper() + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Header().Set("Cache-Control", "no-store") + fmt.Fprint(w, "Profile contract") + })) + t.Cleanup(origin.Close) + secureHits := &atomic.Int32{} + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect { + origin.Config.Handler.ServeHTTP(w, r) + return + } + if r.Host == "profiles.invalid:443" { + secureHits.Add(1) + http.Error(w, "HTTPS routing fixture rejects tunnels", http.StatusBadGateway) + return + } + if r.Host != "profiles.invalid:80" { + http.Error(w, "unexpected target", http.StatusBadGateway) + return + } + remote, err := net.DialTimeout("tcp", origin.Listener.Addr().String(), 5*time.Second) + if err != nil { + http.Error(w, "origin unavailable", http.StatusBadGateway) + return + } + defer remote.Close() + client, buffer, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer client.Close() + if _, err := buffer.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { + return + } + if err := buffer.Flush(); err != nil { + return + } + go func() { io.Copy(remote, buffer); remote.Close() }() + io.Copy(client, remote) + })) + t.Cleanup(upstream.Close) + upstreamURL, err := url.Parse(upstream.URL) + if err != nil { + t.Fatal(err) + } + proxy, err := webviewproxy.New(func(*http.Request) (*url.URL, error) { return upstreamURL, nil }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { proxy.Close() }) + if runtime.GOOS == "darwin" { + return proxy.SOCKSURL(), secureHits + } + return proxy.URL(), secureHits +} diff --git a/internal/webview/proxy/server.go b/internal/webview/proxy/server.go new file mode 100644 index 00000000..17697d40 --- /dev/null +++ b/internal/webview/proxy/server.go @@ -0,0 +1,341 @@ +// Package proxy provides the host-owned WebView forward proxy. Browser engines +// always use this endpoint; Gopeed decides the upstream route for each request. +package proxy + +import ( + "bufio" + "context" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/armon/go-socks5" + xproxy "golang.org/x/net/proxy" +) + +type Selector func(*http.Request) (*url.URL, error) + +type Server struct { + listener net.Listener + socksListener net.Listener + server *http.Server + transport *http.Transport + selectProxy Selector + mu sync.Mutex + connections map[io.Closer]struct{} + closed bool +} + +func New(selectProxy Selector) (*Server, error) { + if selectProxy == nil { + selectProxy = func(*http.Request) (*url.URL, error) { return nil, nil } + } + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return nil, err + } + s := &Server{listener: listener, selectProxy: selectProxy, connections: make(map[io.Closer]struct{})} + s.transport = &http.Transport{Proxy: selectProxy, DisableKeepAlives: true, TLSHandshakeTimeout: 15 * time.Second, ResponseHeaderTimeout: 30 * time.Second} + s.server = &http.Server{Handler: s, ReadHeaderTimeout: 15 * time.Second} + s.socksListener, err = net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + listener.Close() + return nil, err + } + socks, err := socks5.New(&socks5.Config{Logger: log.New(io.Discard, "", 0), Resolver: unresolvedDNS{}, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + _, port, _ := net.SplitHostPort(address) + scheme := "https" + if port == "80" { + scheme = "http" + } + request := &http.Request{URL: &url.URL{Scheme: scheme, Host: address}, Header: make(http.Header)} + upstream, err := selectProxy(request) + if err != nil { + return nil, err + } + timed, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + conn, err := dialTunnel(timed, address, upstream) + if err != nil { + return nil, err + } + return s.track(conn) + }}) + if err != nil { + listener.Close() + s.socksListener.Close() + return nil, err + } + go s.server.Serve(listener) + go socks.Serve(&trackedListener{Listener: s.socksListener, server: s}) + return s, nil +} + +func (s *Server) URL() string { return "http://" + s.listener.Addr().String() } + +func (s *Server) SOCKSURL() string { return "socks5://" + s.socksListener.Addr().String() } + +func (s *Server) Close() error { + s.mu.Lock() + s.closed = true + connections := make([]io.Closer, 0, len(s.connections)) + for c := range s.connections { + connections = append(connections, c) + } + s.mu.Unlock() + for _, c := range connections { + c.Close() + } + s.socksListener.Close() + s.transport.CloseIdleConnections() + return s.server.Close() +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect { + s.connect(w, r) + return + } + if !r.URL.IsAbs() || (r.URL.Scheme != "http" && r.URL.Scheme != "https") { + http.Error(w, "absolute HTTP URL required", http.StatusBadRequest) + return + } + out := r.Clone(r.Context()) + out.RequestURI = "" + upgrade := out.Header.Get("Upgrade") + stripHopHeaders(out.Header) + if upgrade != "" { + out.Header.Set("Connection", "Upgrade") + out.Header.Set("Upgrade", upgrade) + } + resp, err := s.transport.RoundTrip(out) + if err != nil { + http.Error(w, "upstream request failed", http.StatusBadGateway) + return + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusSwitchingProtocols { + remote, ok := resp.Body.(io.ReadWriteCloser) + if !ok { + http.Error(w, "invalid upstream upgrade", 502) + return + } + client, buffer, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer client.Close() + resp.Body = nil + if err := resp.Write(buffer); err != nil { + return + } + if err := buffer.Flush(); err != nil { + return + } + s.bridge(client, buffer, remote) + return + } + stripHopHeaders(resp.Header) + for k, v := range resp.Header { + w.Header()[k] = v + } + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) +} + +func stripHopHeaders(h http.Header) { + for _, key := range strings.Split(h.Get("Connection"), ",") { + h.Del(strings.TrimSpace(key)) + } + for _, key := range []string{"Connection", "Proxy-Connection", "Proxy-Authorization", "Proxy-Authenticate", "Keep-Alive", "TE", "Trailer", "Transfer-Encoding", "Upgrade"} { + h.Del(key) + } +} + +func (s *Server) connect(w http.ResponseWriter, r *http.Request) { + if _, _, err := net.SplitHostPort(r.Host); err != nil { + http.Error(w, "invalid tunnel destination", 400) + return + } + target := r.Clone(r.Context()) + target.URL = &url.URL{Scheme: "https", Host: r.Host} + upstream, err := s.selectProxy(target) + if err != nil { + http.Error(w, "proxy selection failed", 502) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + remote, err := dialTunnel(ctx, r.Host, upstream) + if err != nil { + http.Error(w, "upstream connection failed", 502) + return + } + defer remote.Close() + client, buffer, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer client.Close() + if _, err := buffer.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { + return + } + if err := buffer.Flush(); err != nil { + return + } + s.bridge(client, buffer, remote) +} + +func (s *Server) bridge(client net.Conn, buffer *bufio.ReadWriter, remote io.ReadWriteCloser) { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.connections[client] = struct{}{} + s.connections[remote] = struct{}{} + s.mu.Unlock() + defer func() { s.mu.Lock(); delete(s.connections, client); delete(s.connections, remote); s.mu.Unlock() }() + + done := make(chan struct{}) + go func() { io.Copy(remote, buffer); remote.Close(); close(done) }() + io.Copy(client, remote) + client.Close() + <-done +} + +type bufferedConn struct { + net.Conn + reader *bufio.Reader +} + +func (c *bufferedConn) Read(p []byte) (int, error) { return c.reader.Read(p) } + +func dialTunnel(ctx context.Context, target string, upstream *url.URL) (net.Conn, error) { + dialer := &net.Dialer{} + if upstream == nil { + return dialer.DialContext(ctx, "tcp", target) + } + if upstream.Scheme == "socks5" || upstream.Scheme == "socks5h" { + u := *upstream + u.Scheme = "socks5" + d, err := xproxy.FromURL(&u, dialer) + if err != nil { + return nil, err + } + return d.(xproxy.ContextDialer).DialContext(ctx, "tcp", target) + } + if upstream.Scheme != "http" && upstream.Scheme != "https" { + return nil, errors.New("unsupported proxy scheme") + } + host := upstream.Host + if upstream.Port() == "" { + port := "80" + if upstream.Scheme == "https" { + port = "443" + } + host = net.JoinHostPort(upstream.Hostname(), port) + } + conn, err := dialer.DialContext(ctx, "tcp", host) + if err != nil { + return nil, err + } + ok := false + defer func() { + if !ok { + conn.Close() + } + }() + if deadline, exists := ctx.Deadline(); exists { + conn.SetDeadline(deadline) + } + if upstream.Scheme == "https" { + secure := tls.Client(conn, &tls.Config{ServerName: upstream.Hostname(), MinVersion: tls.VersionTLS12}) + if err := secure.HandshakeContext(ctx); err != nil { + return nil, err + } + conn = secure + } + request := &http.Request{Method: http.MethodConnect, URL: &url.URL{Opaque: target}, Host: target, Header: make(http.Header)} + if upstream.User != nil { + password, _ := upstream.User.Password() + request.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(upstream.User.Username()+":"+password))) + } + if err := request.Write(conn); err != nil { + return nil, err + } + reader := bufio.NewReader(conn) + response, err := http.ReadResponse(reader, request) + if err != nil { + return nil, err + } + if response.StatusCode != 200 { + response.Body.Close() + return nil, fmt.Errorf("upstream CONNECT status %d", response.StatusCode) + } + conn.SetDeadline(time.Time{}) + ok = true + return &bufferedConn{Conn: conn, reader: reader}, nil +} + +// Leave hostname resolution to the selected upstream (SOCKS remote DNS / CONNECT). +type unresolvedDNS struct{} + +func (unresolvedDNS) Resolve(ctx context.Context, name string) (context.Context, net.IP, error) { + return ctx, nil, nil +} + +type trackedListener struct { + net.Listener + server *Server +} + +func (l *trackedListener) Accept() (net.Conn, error) { + c, err := l.Listener.Accept() + if err != nil { + return nil, err + } + return l.server.track(c) +} + +type trackedConn struct { + net.Conn + server *Server +} + +func (c *trackedConn) Close() error { + err := c.Conn.Close() + c.server.mu.Lock() + delete(c.server.connections, c) + c.server.mu.Unlock() + return err +} +func (s *Server) track(c net.Conn) (net.Conn, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + c.Close() + return nil, net.ErrClosed + } + tracked := &trackedConn{Conn: c, server: s} + s.connections[tracked] = struct{}{} + return tracked, nil +} + +func (c *trackedConn) CloseWrite() error { return closeWrite(c.Conn) } +func (c *bufferedConn) CloseWrite() error { return closeWrite(c.Conn) } +func closeWrite(c net.Conn) error { + if writer, ok := c.(interface{ CloseWrite() error }); ok { + return writer.CloseWrite() + } + return c.Close() +} diff --git a/internal/webview/proxy/server_test.go b/internal/webview/proxy/server_test.go new file mode 100644 index 00000000..217de9e8 --- /dev/null +++ b/internal/webview/proxy/server_test.go @@ -0,0 +1,315 @@ +package proxy + +import ( + "bufio" + "context" + "crypto/tls" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/armon/go-socks5" +) + +func TestRejectedProxyRequests(t *testing.T) { + secret := "upstream-password" + for _, tc := range []struct { + name, method, target string + status int + }{ + {"relative URL", "GET", "/relative", 400}, + {"unsupported URL", "GET", "ftp://example.com/file", 400}, + {"invalid CONNECT", "CONNECT", "example.com", 400}, + {"HTTP selection failure", "GET", "http://example.com/", 502}, + {"CONNECT selection failure", "CONNECT", "example.com:443", 502}, + } { + t.Run(tc.name, func(t *testing.T) { + s := &Server{selectProxy: func(*http.Request) (*url.URL, error) { return nil, errors.New(secret) }} + s.transport = &http.Transport{Proxy: s.selectProxy} + defer s.transport.CloseIdleConnections() + response := httptest.NewRecorder() + s.ServeHTTP(response, httptest.NewRequest(tc.method, tc.target, nil)) + if response.Code != tc.status || strings.Contains(response.Body.String(), secret) { + t.Fatalf("unexpected proxy rejection: %d %q", response.Code, response.Body.String()) + } + }) + } +} + +func TestTunnelRejectsUnsafeUpstreams(t *testing.T) { + rejected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "authentication required", http.StatusProxyAuthRequired) + })) + defer rejected.Close() + secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("untrusted TLS proxy received a request") + })) + defer secure.Close() + for _, upstream := range []string{"ftp://127.0.0.1", rejected.URL, secure.URL} { + t.Run(upstream, func(t *testing.T) { + selected, err := url.Parse(upstream) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + conn, err := dialTunnel(ctx, "example.com:443", selected) + if conn != nil { + conn.Close() + } + if err == nil { + t.Fatal("unsafe upstream unexpectedly accepted") + } + }) + } +} + +func TestRoutesAndAuthentication(t *testing.T) { + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Proxy-Authorization") != "" { + t.Error("proxy credentials leaked to origin") + } + w.Write([]byte("origin")) + })) + defer origin.Close() + secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("secure")) })) + defer secure.Close() + upstream, err := New(func(*http.Request) (*url.URL, error) { return nil, nil }) + if err != nil { + t.Fatal(err) + } + defer upstream.Close() + var authenticated atomic.Int32 + authProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Proxy-Authorization") != "Basic dXNlcjpwYXNz" { + http.Error(w, "authentication required", 407) + return + } + authenticated.Add(1) + upstream.ServeHTTP(w, r) + })) + defer authProxy.Close() + socks, err := socks5.New(&socks5.Config{Credentials: socks5.StaticCredentials{"user": "pass"}}) + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go socks.Serve(listener) + for _, route := range []string{"", authProxy.URL, "socks5://" + listener.Addr().String()} { + t.Run(route, func(t *testing.T) { + var selected *url.URL + if route != "" { + selected, _ = url.Parse(route) + selected.User = url.UserPassword("user", "pass") + } + local, err := New(func(r *http.Request) (*url.URL, error) { return selected, nil }) + if err != nil { + t.Fatal(err) + } + defer local.Close() + proxyURL, _ := url.Parse(local.URL()) + transport := &http.Transport{Proxy: http.ProxyURL(proxyURL), TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 5 * time.Second} + for _, test := range []struct{ url, want string }{{origin.URL, "origin"}, {secure.URL, "secure"}} { + response, err := client.Get(test.url) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(response.Body) + response.Body.Close() + if string(body) != test.want { + t.Fatalf("got status %d, body %q", response.StatusCode, body) + } + } + }) + } + if authenticated.Load() != 2 { + t.Fatalf("expected authenticated HTTP and CONNECT, got %d", authenticated.Load()) + } +} + +func TestRoutingChangesAndFailureDoesNotBypassProxy(t *testing.T) { + var hits atomic.Int32 + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1); w.Write([]byte("direct")) })) + defer origin.Close() + var useProxy atomic.Bool + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("proxied")) })) + upstreamURL, _ := url.Parse(upstream.URL) + local, err := New(func(*http.Request) (*url.URL, error) { + if useProxy.Load() { + return upstreamURL, nil + } + return nil, nil + }) + if err != nil { + t.Fatal(err) + } + defer local.Close() + endpoint, _ := url.Parse(local.URL()) + transport := &http.Transport{Proxy: http.ProxyURL(endpoint)} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 3 * time.Second} + for _, want := range []string{"direct", "proxied", "upstream request failed\n"} { + resp, err := client.Get(origin.URL) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if string(body) != want { + t.Fatalf("got %q want %q", body, want) + } + if want == "direct" { + useProxy.Store(true) + } else if want == "proxied" { + upstream.Close() + } + } + if hits.Load() != 1 { + t.Fatal("failed proxy must not fall back to direct") + } +} + +func TestCloseTerminatesTunnels(t *testing.T) { + echo, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer echo.Close() + go func() { + c, e := echo.Accept() + if e == nil { + defer c.Close() + io.Copy(c, c) + } + }() + s, err := New(func(*http.Request) (*url.URL, error) { return nil, nil }) + if err != nil { + t.Fatal(err) + } + endpoint, _ := url.Parse(s.URL()) + c, err := dialTunnel(context.Background(), echo.Addr().String(), endpoint) + if err != nil { + t.Fatal(err) + } + defer c.Close() + c.SetDeadline(time.Now().Add(3 * time.Second)) + c.Write([]byte("hello\n")) + line, err := bufio.NewReader(c).ReadString('\n') + if err != nil || line != "hello\n" { + t.Fatalf("echo %q %v", line, err) + } + s.Close() + if _, err := c.Read(make([]byte, 1)); err == nil { + t.Fatal("tunnel stayed open") + } +} + +func TestSOCKSIngressPreservesRemoteDNS(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "CONNECT" || r.Host != "unresolved.invalid:80" { + t.Errorf("lost destination: %s %s", r.Method, r.Host) + } + c, b, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer c.Close() + b.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n") + b.Flush() + request, err := http.ReadRequest(b.Reader) + if err != nil { + t.Error(err) + return + } + request.Body.Close() + b.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\nproxied") + b.Flush() + })) + defer upstream.Close() + target, _ := url.Parse(upstream.URL) + local, err := New(func(r *http.Request) (*url.URL, error) { + if r.URL.Host != "unresolved.invalid:80" { + t.Errorf("selector lost hostname: %s", r.URL) + } + return target, nil + }) + if err != nil { + t.Fatal(err) + } + defer local.Close() + endpoint, _ := url.Parse(local.SOCKSURL()) + transport := &http.Transport{Proxy: http.ProxyURL(endpoint)} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 5 * time.Second} + response, err := client.Get("http://unresolved.invalid/") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + if string(body) != "proxied" { + t.Fatalf("unexpected body %q", body) + } +} + +func TestHTTPUpgradeIsForwarded(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Upgrade") != "fixture" { + http.Error(w, "upgrade lost", 400) + return + } + c, b, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer c.Close() + b.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: fixture\r\n\r\n") + b.Flush() + line, err := b.ReadString('\n') + if err != nil { + return + } + b.WriteString(line) + b.Flush() + })) + defer upstream.Close() + local, err := New(nil) + if err != nil { + t.Fatal(err) + } + defer local.Close() + endpoint, _ := url.Parse(local.URL()) + c, err := net.Dial("tcp", endpoint.Host) + if err != nil { + t.Fatal(err) + } + defer c.Close() + c.SetDeadline(time.Now().Add(3 * time.Second)) + io.WriteString(c, "GET "+upstream.URL+"/ HTTP/1.1\r\nHost: fixture\r\nConnection: Upgrade\r\nUpgrade: fixture\r\n\r\n") + reader := bufio.NewReader(c) + response, err := http.ReadResponse(reader, nil) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != 101 { + t.Fatalf("upgrade failed: %s", response.Status) + } + io.WriteString(c, "hello\n") + line, err := reader.ReadString('\n') + if err != nil || line != "hello\n" { + t.Fatalf("upgraded echo %q %v", line, err) + } +} diff --git a/internal/webview/rpcprovider/README.md b/internal/webview/rpcprovider/README.md index 3590f557..5036ddfc 100644 --- a/internal/webview/rpcprovider/README.md +++ b/internal/webview/rpcprovider/README.md @@ -1,5 +1,11 @@ # rpcprovider +The private Flutter native profile bridge is maintained in the +[GopeedLab flutter_inappwebview fork](https://github.com/GopeedLab/flutter_inappwebview/tree/codex/gopeed-profiles). +All three platform overrides pin one commit in `ui/flutter/pubspec.yaml`; +see the fork’s [GOPEED.md](https://github.com/GopeedLab/flutter_inappwebview/blob/codex/gopeed-profiles/GOPEED.md) +for patch scope and upstream synchronization. + `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. diff --git a/internal/webview/rpcprovider/provider.go b/internal/webview/rpcprovider/provider.go index 9a22bea2..607bb5a6 100644 --- a/internal/webview/rpcprovider/provider.go +++ b/internal/webview/rpcprovider/provider.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net" "net/http" @@ -109,9 +110,14 @@ func (p *page) ClearCookies() error { } func (p *page) Close() error { - return p.client.Call(enginewebview.MethodPageClose, enginewebview.PageCloseParams{ + err := p.client.Call(enginewebview.MethodPageClose, enginewebview.PageCloseParams{ PageID: p.id, }, nil) + var rpcErr *enginewebview.RPCError + if errors.As(err, &rpcErr) && rpcErr.Code == enginewebview.ErrorCodePageNotFound { + return nil + } + return err } type Client struct { @@ -193,3 +199,7 @@ func (c *Client) Call(method string, params any, result any) error { } return json.Unmarshal(body.Result, result) } + +func (p *Provider) RemoveProfile(profileID, dataPath string) error { + return p.client.Call(enginewebview.MethodProfileRemove, enginewebview.ProfileRemoveParams{ProfileID: profileID}, nil) +} diff --git a/internal/webview/rpcprovider/provider_integration_test.go b/internal/webview/rpcprovider/provider_integration_test.go index 6ade9458..5f26ca56 100644 --- a/internal/webview/rpcprovider/provider_integration_test.go +++ b/internal/webview/rpcprovider/provider_integration_test.go @@ -37,3 +37,8 @@ func defaultWebViewRPCAddress() string { } return filepath.Join(homeDir, "Library", "Application Support", "com.gopeed.gopeed", "gopeed_webview.sock") } + +func TestProviderProfiles(t *testing.T) { + provider := New(enginewebview.RPCConfig{Network: *webViewRPCNetwork, Address: *webViewRPCAddress, Token: *webViewRPCToken}) + integrationtest.RunProfileContract(t, provider) +} diff --git a/internal/webview/rpcprovider/provider_test.go b/internal/webview/rpcprovider/provider_test.go index 9ec03ac1..0a28cd9f 100644 --- a/internal/webview/rpcprovider/provider_test.go +++ b/internal/webview/rpcprovider/provider_test.go @@ -184,3 +184,29 @@ func TestPageSetCookieParamsOmitsZeroExpires(t *testing.T) { t.Fatalf("expected expires in payload: %s", got) } } + +func TestRemoveProfileUsesPrivateHostRPC(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Method string `json:"method"` + Params map[string]string `json:"params"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + } + if request.Method != enginewebview.MethodProfileRemove || request.Params["profileId"] != "extension-profile" || len(request.Params) != 1 { + t.Errorf("unexpected profile removal request: %+v", request) + } + if r.Header.Get("Authorization") != "Bearer secret" { + t.Error("missing host authentication") + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"error":{"code":"PROFILE_REMOVE_FAILED","message":"store busy"}}`)) + })) + defer server.Close() + provider := New(enginewebview.RPCConfig{Network: "tcp", Address: strings.TrimPrefix(server.URL, "http://"), Token: "secret"}) + err := provider.(enginewebview.ProfileRemover).RemoveProfile("extension-profile", "/host-only/path") + if err == nil || err.Error() != "store busy" { + t.Fatalf("cleanup failure was lost: %v", err) + } +} diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index 72953a8e..d3a4ff6a 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -21,6 +21,7 @@ import ( "github.com/GopeedLab/gopeed/internal/controller" "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/internal/logger" + webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/protocol/http" "github.com/GopeedLab/gopeed/pkg/util" @@ -97,8 +98,12 @@ type Progress struct { } type Downloader struct { - Logger *logger.Logger - ExtensionLogger *logger.Logger + webviewProfilesLock sync.Mutex + webviewProfiles map[string]*extensionWebViewProfile + webviewProxyLock sync.Mutex + webviewProxy *webviewproxy.Server + Logger *logger.Logger + ExtensionLogger *logger.Logger cfg *DownloaderConfig fetcherCache map[string]fetcher.Fetcher @@ -1114,6 +1119,11 @@ func (d *Downloader) doDelete(task *Task, force bool) (err error) { func (d *Downloader) Close() error { d.closed.Store(true) + d.webviewProxyLock.Lock() + if d.webviewProxy != nil { + _ = d.webviewProxy.Close() + } + d.webviewProxyLock.Unlock() closeArr := []func() error{ d.pauseAll, diff --git a/pkg/download/engine/webview/rpc.go b/pkg/download/engine/webview/rpc.go index 5dab17ef..77c91976 100644 --- a/pkg/download/engine/webview/rpc.go +++ b/pkg/download/engine/webview/rpc.go @@ -5,6 +5,7 @@ import "encoding/json" const RPCEndpointPath = "/webview" const ( + MethodProfileRemove = "profile.remove" MethodIsAvailable = "webview.isAvailable" MethodPageOpen = "page.open" MethodPageAddInitScript = "page.addInitScript" @@ -68,6 +69,9 @@ type IsAvailableResult struct { } type PageOpenParams struct { + ProfileID string `json:"profileId,omitempty"` + DataPath string `json:"dataPath,omitempty"` + ProxyURL string `json:"proxyUrl,omitempty"` Headless bool `json:"headless,omitempty"` Debug bool `json:"debug,omitempty"` Title string `json:"title,omitempty"` @@ -78,6 +82,9 @@ type PageOpenParams struct { func NewPageOpenParams(opts OpenOptions) PageOpenParams { return PageOpenParams{ + ProfileID: opts.ProfileID, + DataPath: opts.DataPath, + ProxyURL: opts.ProxyURL, Headless: opts.Headless, Debug: opts.Debug, Title: opts.Title, @@ -141,3 +148,8 @@ type PageCloseParams struct { } type EmptyResult struct{} + +// ProfileRemoveParams belongs to the private host RPC. +type ProfileRemoveParams struct { + ProfileID string `json:"profileId"` +} diff --git a/pkg/download/engine/webview/runtime.go b/pkg/download/engine/webview/runtime.go index 64a236e4..cc115826 100644 --- a/pkg/download/engine/webview/runtime.go +++ b/pkg/download/engine/webview/runtime.go @@ -17,6 +17,11 @@ type Opener interface { Open(opts OpenOptions) (Page, error) } +// ProfileRemover is a host-only lifecycle operation, never exposed to extension JS. +type ProfileRemover interface { + RemoveProfile(profileID, dataPath string) error +} + type Page interface { AddInitScript(script string) error Goto(url string, opts GotoOptions) error @@ -29,6 +34,10 @@ type Page interface { } type OpenOptions struct { + // Host-owned fields are never parsed from extension JavaScript options. + ProfileID string + DataPath string + ProxyURL string Headless bool Debug bool Title string diff --git a/pkg/download/engine/webview/runtime_test.go b/pkg/download/engine/webview/runtime_test.go index 066dea2e..6f963055 100644 --- a/pkg/download/engine/webview/runtime_test.go +++ b/pkg/download/engine/webview/runtime_test.go @@ -336,3 +336,15 @@ func (p *fakePage) Close() error { p.closed = true return nil } + +func TestExtensionCannotSupplyHostProfileOrProxy(t *testing.T) { + opener := &fakeOpener{page: &fakePage{}} + runtime := NewRuntime(opener, true) + _, err := runtime.Open(map[string]any{"profileId": "other-extension", "dataPath": "/tmp/other-profile", "proxyUrl": "http://example.com:1234", "ProfileID": "other"}) + if err != nil { + t.Fatal(err) + } + if opener.opts.ProfileID != "" || opener.opts.DataPath != "" || opener.opts.ProxyURL != "" { + t.Fatalf("extension controlled host settings: %+v", opener.opts) + } +} diff --git a/pkg/download/extension.go b/pkg/download/extension.go index c9bb7832..53f9e989 100644 --- a/pkg/download/extension.go +++ b/pkg/download/extension.go @@ -65,6 +65,7 @@ func (d *Downloader) InstallExtensionByFolder(path string, devMode bool) (*Exten // if extension is not installed, add it to the list, otherwise update it installedExt := d.getExtension(ext.Identity) + newInstallation := installedExt == nil if installedExt == nil { ext.CreatedAt = time.Now() ext.UpdatedAt = ext.CreatedAt @@ -76,6 +77,11 @@ func (d *Downloader) InstallExtensionByFolder(path string, devMode bool) (*Exten if err = d.storage.Put(bucketExtension, installedExt.Identity, installedExt); err != nil { return nil, err } + if newInstallation { + d.webviewProfilesLock.Lock() + delete(d.webviewProfiles, ext.Identity) + d.webviewProfilesLock.Unlock() + } return installedExt, nil } @@ -144,6 +150,9 @@ func (d *Downloader) DeleteExtension(identity string) error { if err != nil { return err } + if err := d.removeExtensionWebViewProfile(identity); err != nil { + return fmt.Errorf("remove extension WebView data: %w", err) + } // remove from disk if !ext.DevMode { if err := os.RemoveAll(d.ExtensionPath(ext)); err != nil { @@ -365,7 +374,7 @@ func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, c engine, session := d.newExtensionEngine() defer session.CloseIfIdle() gopeed.Runtime = &InstanceRuntime{ - WebView: d.newExtensionWebViewRuntime(session), + WebView: d.newExtensionWebViewRuntime(session, ext.buildIdentity()), } err = injectGopeed(engine.Runtime, gopeed) if err != nil { diff --git a/pkg/download/extension_engine.go b/pkg/download/extension_engine.go index 39b832f1..4ef05d23 100644 --- a/pkg/download/extension_engine.go +++ b/pkg/download/extension_engine.go @@ -30,7 +30,7 @@ func (d *Downloader) NewExtensionEngine(ext *Extension, settings map[string]any) identity: ext.buildIdentity(), }, Runtime: &InstanceRuntime{ - WebView: d.newExtensionWebViewRuntime(session), + WebView: d.newExtensionWebViewRuntime(session, ext.buildIdentity()), }, } if err := injectGopeed(engine.Runtime, gopeed); err != nil { diff --git a/pkg/download/extension_engine_session.go b/pkg/download/extension_engine_session.go index 4f248c88..45b89dcb 100644 --- a/pkg/download/extension_engine_session.go +++ b/pkg/download/extension_engine_session.go @@ -135,13 +135,13 @@ func (d *Downloader) newExtensionEngine() (*engine.Engine, *engineSession) { return e, session } -func (d *Downloader) newExtensionWebViewRuntime(session *engineSession) *enginewebview.Runtime { +func (d *Downloader) newExtensionWebViewRuntime(session *engineSession, identity string) *enginewebview.Runtime { var ( opener enginewebview.Opener available bool ) if provider := d.cfg.WebViewProvider; provider != nil && provider.IsAvailable() { - opener = provider + opener = &extensionWebViewOpener{downloader: d, provider: provider, identity: identity, profile: d.extensionWebViewProfile(identity)} available = true } runtime := enginewebview.NewRuntime(opener, available) diff --git a/pkg/download/extension_webview_config.go b/pkg/download/extension_webview_config.go new file mode 100644 index 00000000..164df1e4 --- /dev/null +++ b/pkg/download/extension_webview_config.go @@ -0,0 +1,183 @@ +package download + +import ( + "crypto/sha256" + "fmt" + "net/http" + "net/url" + "os" + "sync" + + "github.com/GopeedLab/gopeed/pkg/base" + "path/filepath" + "runtime" + + webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy" + enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" +) + +// extensionWebViewOpener binds browser state to the owning extension. Neither +// identity, filesystem paths nor proxy settings are accepted from JavaScript. +type extensionWebViewOpener struct { + downloader *Downloader + provider enginewebview.Provider + identity string + profile *extensionWebViewProfile +} + +func (o *extensionWebViewOpener) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) { + if o.identity == "" { + return nil, fmt.Errorf("webview extension identity is required") + } + profile := o.profile + if profile == nil { + profile = o.downloader.extensionWebViewProfile(o.identity) + } + profile.mu.Lock() + defer profile.mu.Unlock() + if profile.revoked { + return nil, fmt.Errorf("extension WebView profile has been removed") + } + id, path, err := o.downloader.extensionWebViewLocation(o.identity) + if err != nil { + return nil, err + } + opts.ProfileID, opts.DataPath = id, path + opts.ProxyURL, err = o.downloader.webviewProxyURL() + if err != nil { + return nil, err + } + page, err := o.provider.Open(opts) + if err != nil { + return nil, err + } + tracked := &extensionWebViewPage{Page: page, profile: profile} + profile.pages[tracked] = struct{}{} + return tracked, nil +} + +func (d *Downloader) webviewProxyURL() (string, error) { + d.webviewProxyLock.Lock() + defer d.webviewProxyLock.Unlock() + if d.closed.Load() { + return "", fmt.Errorf("downloader is closed") + } + if d.webviewProxy == nil { + server, err := webviewproxy.New(func(r *http.Request) (*url.URL, error) { + if d.blob != nil && d.blob.IsURL(r.URL.String()) { + return nil, nil + } + d.configLock.RLock() + var cfg base.DownloaderProxyConfig + if d.cfg.Proxy != nil { + cfg = *d.cfg.Proxy + } + d.configLock.RUnlock() + handler := cfg.ToHandler() + if handler == nil { + return nil, nil + } + return handler(r) + }) + if err != nil { + return "", err + } + d.webviewProxy = server + } + if runtime.GOOS == "darwin" || runtime.GOOS == "ios" { + return d.webviewProxy.SOCKSURL(), nil + } + return d.webviewProxy.URL(), nil +} + +// A revoked profile is retained until a fresh installation. Existing runtimes +// retain this object, so reinstalling cannot let an old runtime recreate data. +type extensionWebViewProfile struct { + mu sync.Mutex + deleteMu sync.Mutex + revoked bool + pages map[*extensionWebViewPage]struct{} +} + +type extensionWebViewPage struct { + enginewebview.Page + profile *extensionWebViewProfile + mu sync.Mutex + closed bool +} + +func (p *extensionWebViewPage) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + if p.closed { + return nil + } + if err := p.Page.Close(); err != nil { + return err + } + p.closed = true + p.profile.mu.Lock() + delete(p.profile.pages, p) + p.profile.mu.Unlock() + return nil +} + +func (d *Downloader) extensionWebViewProfile(identity string) *extensionWebViewProfile { + d.webviewProfilesLock.Lock() + defer d.webviewProfilesLock.Unlock() + if d.webviewProfiles == nil { + d.webviewProfiles = make(map[string]*extensionWebViewProfile) + } + profile := d.webviewProfiles[identity] + if profile == nil { + profile = &extensionWebViewProfile{pages: make(map[*extensionWebViewPage]struct{})} + d.webviewProfiles[identity] = profile + } + return profile +} + +func (d *Downloader) extensionWebViewLocation(identity string) (string, string, error) { + if identity == "" { + return "", "", fmt.Errorf("webview extension identity is required") + } + sum := sha256.Sum256([]byte(identity)) + id := fmt.Sprintf("%x-%x-%x-%x-%x", sum[:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16]) + path, err := filepath.Abs(filepath.Join(d.cfg.StorageDir, "webview", fmt.Sprintf("%x", sum[:]))) + return id, path, err +} + +func (d *Downloader) removeExtensionWebViewProfile(identity string) error { + profile := d.extensionWebViewProfile(identity) + profile.deleteMu.Lock() + defer profile.deleteMu.Unlock() + profile.mu.Lock() + profile.revoked = true + pages := make([]*extensionWebViewPage, 0, len(profile.pages)) + for page := range profile.pages { + pages = append(pages, page) + } + profile.mu.Unlock() + for _, page := range pages { + if err := page.Close(); err != nil { + return err + } + } + id, path, err := d.extensionWebViewLocation(identity) + if err != nil { + return err + } + if provider := d.cfg.WebViewProvider; provider != nil { + remover, ok := provider.(enginewebview.ProfileRemover) + if !ok { + // An unavailable backend cannot have created a profile this run. + // Still fail if a persisted native profile directory exists. + _, statErr := os.Stat(path) + if provider.IsAvailable() || len(pages) != 0 || !os.IsNotExist(statErr) { + return fmt.Errorf("WebView provider does not support profile removal") + } + } else if err := remover.RemoveProfile(id, path); err != nil { + return err + } + } + return os.RemoveAll(path) +} diff --git a/pkg/download/extension_webview_config_test.go b/pkg/download/extension_webview_config_test.go new file mode 100644 index 00000000..2c972970 --- /dev/null +++ b/pkg/download/extension_webview_config_test.go @@ -0,0 +1,51 @@ +package download + +import ( + "path/filepath" + "sync" + "testing" + + "github.com/GopeedLab/gopeed/pkg/base" + enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" +) + +type profileCaptureProvider struct{ options []enginewebview.OpenOptions } + +func (*profileCaptureProvider) IsAvailable() bool { return true } +func (p *profileCaptureProvider) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) { + p.options = append(p.options, opts) + return fakeRuntimeWebViewPage{}, nil +} + +func TestWebViewProfileBoundToExtension(t *testing.T) { + root := t.TempDir() + d := &Downloader{cfg: &DownloaderConfig{StorageDir: root, DownloaderStoreConfig: &base.DownloaderStoreConfig{}}, configLock: &sync.RWMutex{}} + t.Cleanup(func() { + if d.webviewProxy != nil { + d.webviewProxy.Close() + } + }) + provider := &profileCaptureProvider{} + for _, id := range []string{"extension-a", "extension-b", "extension-a", "../../escape"} { + opener := &extensionWebViewOpener{downloader: d, provider: provider, identity: id} + _, err := opener.Open(enginewebview.OpenOptions{ProfileID: "forged", DataPath: "/tmp/forged", ProxyURL: "http://wrong:80"}) + if err != nil { + t.Fatal(err) + } + } + a, b, reopen := provider.options[0], provider.options[1], provider.options[2] + if a.ProfileID == b.ProfileID || a.DataPath == b.DataPath { + t.Fatal("different extensions shared browser storage") + } + if a.ProfileID != reopen.ProfileID || a.DataPath != reopen.DataPath { + t.Fatal("same extension lost profile identity") + } + for _, opts := range provider.options { + if filepath.Dir(opts.DataPath) != filepath.Join(root, "webview") { + t.Fatalf("profile escaped storage root: %s", opts.DataPath) + } + if opts.ProxyURL != d.webviewProxy.URL() && opts.ProxyURL != d.webviewProxy.SOCKSURL() { + t.Fatal("host proxy was overridden") + } + } +} diff --git a/pkg/download/extension_webview_remove_test.go b/pkg/download/extension_webview_remove_test.go new file mode 100644 index 00000000..50bc3191 --- /dev/null +++ b/pkg/download/extension_webview_remove_test.go @@ -0,0 +1,189 @@ +package download + +import ( + "errors" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/GopeedLab/gopeed/pkg/base" + webview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" +) + +type removalProvider struct { + pages map[string][]*removalPage + removed []string + failure error + entered chan struct{} + resume chan struct{} +} +type removalPage struct { + fakeRuntimeWebViewPage + closed bool +} + +func (p *removalPage) Close() error { p.closed = true; return nil } +func (p *removalProvider) IsAvailable() bool { return true } +func (p *removalProvider) Open(opts webview.OpenOptions) (webview.Page, error) { + if p.entered != nil { + close(p.entered) + <-p.resume + } + if p.pages == nil { + p.pages = make(map[string][]*removalPage) + } + page := &removalPage{} + p.pages[opts.ProfileID] = append(p.pages[opts.ProfileID], page) + return page, nil +} +func (p *removalProvider) RemoveProfile(id, path string) error { + for _, page := range p.pages[id] { + if !page.closed { + return errors.New("profile removed before closing pages") + } + } + if p.failure != nil { + return p.failure + } + p.removed = append(p.removed, id) + return nil +} +func removalDownloader(t *testing.T, provider *removalProvider) *Downloader { + t.Helper() + storage := NewMemStorage() + if err := storage.Setup([]string{bucketExtension, bucketExtensionStorage}); err != nil { + t.Fatal(err) + } + d := &Downloader{cfg: &DownloaderConfig{StorageDir: t.TempDir(), DownloaderStoreConfig: &base.DownloaderStoreConfig{}, WebViewProvider: provider}, storage: storage, configLock: &sync.RWMutex{}} + t.Cleanup(func() { + if d.webviewProxy != nil { + d.webviewProxy.Close() + } + }) + return d +} +func profileSentinel(t *testing.T, d *Downloader, id string) string { + t.Helper() + _, path, err := d.extensionWebViewLocation(id) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + sentinel := filepath.Join(path, "cookies.sqlite") + if err := os.WriteFile(sentinel, []byte("private browser state"), 0600); err != nil { + t.Fatal(err) + } + return sentinel +} +func TestDeleteExtensionRemovesWebViewData(t *testing.T) { + provider := &removalProvider{} + d := removalDownloader(t, provider) + ext, err := d.InstallExtensionByFolder("testdata/extensions/basic", true) + if err != nil { + t.Fatal(err) + } + identity := ext.Identity + opener := &extensionWebViewOpener{downloader: d, provider: provider, identity: identity, profile: d.extensionWebViewProfile(identity)} + for i := 0; i < 2; i++ { + if _, err := opener.Open(webview.OpenOptions{}); err != nil { + t.Fatal(err) + } + } + sentinel := profileSentinel(t, d, identity) + other := profileSentinel(t, d, "other-extension") + // Updating the installation keeps both its profile object and stored data. + if _, err := d.InstallExtensionByFolder("testdata/extensions/basic", true); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(sentinel); err != nil { + t.Fatal("upgrade removed browser data", err) + } + if err := d.DeleteExtension(identity); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(sentinel); !os.IsNotExist(err) { + t.Fatalf("browser data retained: %v", err) + } + if _, err := os.Stat(other); err != nil { + t.Fatal("other extension data was removed", err) + } + if _, err := os.Stat(ext.DevPath); err != nil { + t.Fatal("dev source was removed", err) + } + if len(provider.removed) != 1 { + t.Fatal("native profile was not removed") + } + if _, err := opener.Open(webview.OpenOptions{}); err == nil { + t.Fatal("old runtime recreated removed profile") + } + if _, err := d.InstallExtensionByFolder("testdata/extensions/basic", true); err != nil { + t.Fatal(err) + } + fresh := &extensionWebViewOpener{downloader: d, provider: provider, identity: identity, profile: d.extensionWebViewProfile(identity)} + if _, err := fresh.Open(webview.OpenOptions{}); err != nil { + t.Fatal("reinstall could not open profile", err) + } + if _, err := opener.Open(webview.OpenOptions{}); err == nil { + t.Fatal("reinstall reactivated old runtime") + } +} +func TestDeleteExtensionProfileFailureIsRetryable(t *testing.T) { + provider := &removalProvider{failure: errors.New("native store busy")} + d := removalDownloader(t, provider) + ext, err := d.InstallExtensionByFolder("testdata/extensions/basic", true) + if err != nil { + t.Fatal(err) + } + sentinel := profileSentinel(t, d, ext.Identity) + if err := d.DeleteExtension(ext.Identity); err == nil { + t.Fatal("cleanup failure was swallowed") + } + if _, err := d.GetExtension(ext.Identity); err != nil { + t.Fatal("extension removed before cleanup completed") + } + if _, err := os.Stat(sentinel); err != nil { + t.Fatal("profile path removed before native cleanup completed") + } + provider.failure = nil + if err := d.DeleteExtension(ext.Identity); err != nil { + t.Fatal(err) + } + if _, err := d.GetExtension(ext.Identity); !errors.Is(err, ErrExtensionNotFound) { + t.Fatal("extension was not uninstalled") + } +} +func TestRemoveProfileWaitsForOpeningPage(t *testing.T) { + provider := &removalProvider{entered: make(chan struct{}), resume: make(chan struct{})} + d := removalDownloader(t, provider) + opener := &extensionWebViewOpener{downloader: d, provider: provider, identity: "race", profile: d.extensionWebViewProfile("race")} + opened := make(chan error, 1) + go func() { _, err := opener.Open(webview.OpenOptions{}); opened <- err }() + <-provider.entered + removed := make(chan error, 1) + go func() { removed <- d.removeExtensionWebViewProfile("race") }() + close(provider.resume) + if err := <-opened; err != nil { + t.Fatal(err) + } + if err := <-removed; err != nil { + t.Fatal(err) + } + if len(provider.removed) != 1 { + t.Fatal("profile not removed") + } +} + +func TestUninstallWithoutAvailableWebView(t *testing.T) { + d := removalDownloader(t, &removalProvider{}) + d.cfg.WebViewProvider = webview.NewUnavailableProvider() + ext, err := d.InstallExtensionByFolder("testdata/extensions/basic", true) + if err != nil { + t.Fatal(err) + } + if err := d.DeleteExtension(ext.Identity); err != nil { + t.Fatal(err) + } +} diff --git a/ui/flutter/analysis_options.yaml b/ui/flutter/analysis_options.yaml index 89f7f921..a1a8bbfc 100644 --- a/ui/flutter/analysis_options.yaml +++ b/ui/flutter/analysis_options.yaml @@ -29,3 +29,7 @@ linter: formatter: page_width: 120 + +analyzer: + exclude: + - third_party/** diff --git a/ui/flutter/lib/app/rpc/webview_rpc_service.dart b/ui/flutter/lib/app/rpc/webview_rpc_service.dart index 653e78e6..b138abd0 100644 --- a/ui/flutter/lib/app/rpc/webview_rpc_service.dart +++ b/ui/flutter/lib/app/rpc/webview_rpc_service.dart @@ -3,6 +3,7 @@ import 'dart:collection'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import '../../core/common/start_config.dart'; @@ -73,7 +74,6 @@ class WebViewRpcService { final Map _pagesById = {}; final Completer _overlayReady = Completer(); - late final CookieManager _cookieManager = CookieManager.instance(); RpcServerHandle? _server; int _pageSeq = 0; @@ -129,6 +129,9 @@ class WebViewRpcService { switch (method) { case 'webview.isAvailable': return {'available': true}; + case 'profile.remove': + await _removeProfile(_string(params, 'profileId')); + return {}; case 'page.open': return _openPage(params); case 'page.addInitScript': @@ -162,10 +165,12 @@ class WebViewRpcService { } Future> _openPage(Map params) async { + final profile = WebViewProfile(_string(params, 'profileId')); + await profile.prepare(proxyUrl: Uri.parse(_string(params, 'proxyUrl'))); final pageId = 'page-${++_pageSeq}'; final session = WebViewRpcPageSession( pageId: pageId, - cookieManager: _cookieManager, + profile: profile, headless: params['headless'] as bool? ?? false, debug: params['debug'] as bool? ?? false, title: params['title'] as String? ?? '', @@ -185,6 +190,15 @@ class WebViewRpcService { return {'pageId': pageId}; } + Future _removeProfile(String id) async { + for (final page in _pagesById.values.where((page) => page.profile.id == id).toList()) { + await _closePage(page.pageId); + } + // Let visible platform views unmount before releasing their native store. + await WidgetsBinding.instance.endOfFrame; + await WebViewProfile(id).remove(); + } + WebViewRpcPageSession _page(Map params) { final page = _pagesById[_pageId(params)]; if (page == null) { @@ -271,6 +285,9 @@ class WebViewRpcService { if (error is WebViewRpcException) { return {'code': error.code, 'message': error.message}; } + if (error is PlatformException && error.code == 'UNAVAILABLE') { + return {'code': error.code, 'message': error.message ?? 'WebView is unavailable'}; + } final code = switch (method) { 'page.goto' => 'NAVIGATION_FAILED', 'page.execute' => 'EVALUATION_FAILED', @@ -283,7 +300,7 @@ class WebViewRpcService { class WebViewRpcPageSession { WebViewRpcPageSession({ required this.pageId, - required this.cookieManager, + required this.profile, required this.headless, required this.debug, required this.title, @@ -293,7 +310,8 @@ class WebViewRpcPageSession { }); final String pageId; - final CookieManager cookieManager; + final WebViewProfile profile; + late final CookieManager cookieManager = profile.cookieManager; final bool headless; final bool debug; final String title; @@ -449,6 +467,8 @@ class WebViewRpcPageSession { 'path': cookie.path ?? '/', 'secure': cookie.isSecure, 'httpOnly': cookie.isHttpOnly, + if (cookie.expiresDate != null) + 'expires': DateTime.fromMillisecondsSinceEpoch(cookie.expiresDate!, isUtc: true).toIso8601String(), }; }) .toList(growable: false); @@ -524,6 +544,7 @@ class WebViewRpcPageSession { } InAppWebViewSettings get _settings => InAppWebViewSettings( + profileId: profile.id, javaScriptEnabled: true, transparentBackground: true, isInspectable: debug, diff --git a/ui/flutter/macos/Runner/Info.plist b/ui/flutter/macos/Runner/Info.plist index cee06fe7..48add6d5 100644 --- a/ui/flutter/macos/Runner/Info.plist +++ b/ui/flutter/macos/Runner/Info.plist @@ -2,6 +2,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoadsInWebContent + + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock index e3384198..06828e78 100644 --- a/ui/flutter/pubspec.lock +++ b/ui/flutter/pubspec.lock @@ -457,18 +457,20 @@ packages: flutter_inappwebview: dependency: "direct main" description: - name: flutter_inappwebview - sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" - url: "https://pub.dev" - source: hosted + path: flutter_inappwebview + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + resolved-ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + url: "https://github.com/GopeedLab/flutter_inappwebview.git" + source: git version: "6.1.5" flutter_inappwebview_android: - dependency: transitive + dependency: "direct overridden" description: - name: flutter_inappwebview_android - sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" - url: "https://pub.dev" - source: hosted + path: flutter_inappwebview_android + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + resolved-ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + url: "https://github.com/GopeedLab/flutter_inappwebview.git" + source: git version: "1.1.3" flutter_inappwebview_internal_annotations: dependency: transitive @@ -479,28 +481,31 @@ packages: source: hosted version: "1.3.0" flutter_inappwebview_ios: - dependency: transitive + dependency: "direct overridden" description: - name: flutter_inappwebview_ios - sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" - url: "https://pub.dev" - source: hosted + path: flutter_inappwebview_ios + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + resolved-ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + url: "https://github.com/GopeedLab/flutter_inappwebview.git" + source: git version: "1.1.2" flutter_inappwebview_macos: - dependency: transitive + dependency: "direct overridden" description: - name: flutter_inappwebview_macos - sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 - url: "https://pub.dev" - source: hosted + path: flutter_inappwebview_macos + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + resolved-ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + url: "https://github.com/GopeedLab/flutter_inappwebview.git" + source: git version: "1.1.2" flutter_inappwebview_platform_interface: - dependency: transitive + dependency: "direct overridden" description: - name: flutter_inappwebview_platform_interface - sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 - url: "https://pub.dev" - source: hosted + path: flutter_inappwebview_platform_interface + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + resolved-ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + url: "https://github.com/GopeedLab/flutter_inappwebview.git" + source: git version: "1.3.0+1" flutter_inappwebview_web: dependency: transitive diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index cb27bf83..405a645f 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -86,6 +86,32 @@ dependencies: go_router: ^16.2.1 dependency_overrides: + # Public persistent-profile and proxy APIs; keep all packages on one commit. + flutter_inappwebview: + git: + url: https://github.com/GopeedLab/flutter_inappwebview.git + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + path: flutter_inappwebview + flutter_inappwebview_platform_interface: + git: + url: https://github.com/GopeedLab/flutter_inappwebview.git + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + path: flutter_inappwebview_platform_interface + flutter_inappwebview_macos: + git: + url: https://github.com/GopeedLab/flutter_inappwebview.git + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + path: flutter_inappwebview_macos + flutter_inappwebview_ios: + git: + url: https://github.com/GopeedLab/flutter_inappwebview.git + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + path: flutter_inappwebview_ios + flutter_inappwebview_android: + git: + url: https://github.com/GopeedLab/flutter_inappwebview.git + ref: d579215760e1c1e58a4ff7601a6055b5e6adf8e8 + path: flutter_inappwebview_android # Prevent a native Windows crash when the push notification service is unavailable. # Keep this fork until https://github.com/MaikuB/flutter_local_notifications/pull/2813 is merged and released. flutter_local_notifications: