diff --git a/go.mod b/go.mod index b01d1f1e..d1cc101a 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.4 require ( codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0 - github.com/GopeedLab/webview_go v0.0.0-20260915010614-2567e492369a + github.com/GopeedLab/webview_go v0.0.0-20260917153919-dcf00737f32a 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 diff --git a/go.sum b/go.sum index 98c4c2eb..daf997cc 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 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/GopeedLab/webview_go v0.0.0-20260917153919-dcf00737f32a h1:nz1bC6TGwQ1kv/OVLahNZ0EEtb7v5LHfceAkW4MvS6I= +github.com/GopeedLab/webview_go v0.0.0-20260917153919-dcf00737f32a/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 index bd46c554..8025132f 100644 --- a/internal/webview/README.md +++ b/internal/webview/README.md @@ -60,3 +60,47 @@ 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. + +## Extension events and host version + +`gopeed.info.hostVersion` is the host's build version (`dev` for development +builds). `gopeed.info.version` continues to identify the extension's own version. +See [extension-api.d.ts](extension-api.d.ts) for the event payloads. + +```js +const page = await gopeed.runtime.webview.open(); +const unsubscribe = await page.on("url-changed", ({ url, sameDocument }) => { + gopeed.logger.info(url, sameDocument); +}); +await page.on("closed", ({ reason }) => gopeed.logger.info(reason)); +await page.addInitScript("globalThis.extensionReady = true"); +await page.goto("https://example.com"); +unsubscribe(); +await page.close(); +``` + +Registration resolves only after the listener is installed; it does not replay +current state. `url-changed` reports actual address changes, including hash, +pushState, replaceState and history navigation. `load` follows the main document's +`window.load`, including reloads, and does not wait for business-level async work. +Iframe loads and subresource failures do not become main-page notifications. +`closed` distinguishes the user's close action from API closure and is terminal. +Unsubscribe is idempotent. Notifications cannot block navigation; the engine does +not await promises returned by handlers. Catch async errors inside the handler. + +`addInitScript` applies to future document contexts only. Use `execute` to run +code on the current page. Executable functions are serialized and cannot capture +extension-side lexical variables; pass values explicitly as arguments. + +The RPC host uses an NDJSON `page.events` response whose first record is +`{"ready":true}`. Subsequent records are `{event,data}`. Closing a page sends the +terminal record before ending the stream. The native backend uses document-start +notifications for main-document URL/load events and native callbacks for +navigation failures and user closure. + +Additional validation: + +- `go test -race ./pkg/download/engine/webview ./internal/webview/rpcprovider` +- `go test ./pkg/download -run 'TestExtension(WebViewEvents|InfoHostVersion)'` +- macOS native: `go test -tags webview_native ./internal/webview/goprovider -run TestProviderEvents` +- Running Flutter host: `go test -tags webview ./internal/webview/rpcprovider -run TestProviderEvents` diff --git a/internal/webview/extension-api.d.ts b/internal/webview/extension-api.d.ts new file mode 100644 index 00000000..fa5528dd --- /dev/null +++ b/internal/webview/extension-api.d.ts @@ -0,0 +1,48 @@ +/** Public extension API additions. info.version remains the extension version. */ +export interface ExtensionInfo { + identity: string; + name: string; + author: string; + title: string; + version: string; + hostVersion: string; +} + +/** Functions are serialized; they cannot capture extension-side variables. */ +export type WebviewExecutable = + | string + | ((...args: unknown[]) => T | Promise); + +export type WebviewUnsubscribe = () => void; + +/** Navigation and load notifications are for the main document only. */ +export interface WebviewEventMap { + "url-changed": { url: string; sameDocument: boolean }; + /** window.load, including reloads, excluding hash/History API navigation. */ + "load": { url: string }; + "load-error": { url: string; message: string }; + "closed": { reason: "user" | "api" }; +} + +export interface WebviewGotoOptions { + timeoutMs?: number; + waitUntil?: "load" | "domcontentloaded"; +} + +/** These members extend the existing WebviewPage API. */ +export interface WebviewPage { + /** + * Resolves when listening is ready. Does not replay current state. + * Register before goto() to observe navigation. Unsubscribe is idempotent. + * Closed is delivered once, then listeners are released automatically. + * Callbacks cannot cancel navigation; returned promises are not awaited. + * Callers handle errors in asynchronous callbacks. + */ + on( + event: K, + handler: (data: WebviewEventMap[K]) => void, + ): Promise; + /** Runs at document creation on future navigations, never immediately. */ + addInitScript(script: string): Promise; + goto(url: string, opts?: WebviewGotoOptions): Promise; +} diff --git a/internal/webview/goprovider/events_darwin_test.go b/internal/webview/goprovider/events_darwin_test.go new file mode 100644 index 00000000..a66132e8 --- /dev/null +++ b/internal/webview/goprovider/events_darwin_test.go @@ -0,0 +1,12 @@ +//go:build cgo && darwin && webview_native + +package goprovider + +import ( + "github.com/GopeedLab/gopeed/internal/webview/integrationtest" + "os" + "testing" +) + +func TestMain(m *testing.M) { os.Exit(RunMainThreadLoop(m.Run)) } +func TestProviderEvents(t *testing.T) { integrationtest.RunEventContract(t, New()) } diff --git a/internal/webview/goprovider/provider.go b/internal/webview/goprovider/provider.go index c9d06a5e..f9fca852 100644 --- a/internal/webview/goprovider/provider.go +++ b/internal/webview/goprovider/provider.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "net/url" "strings" "sync" "sync/atomic" @@ -37,7 +36,9 @@ func (p *Provider) Open(opts enginewebview.OpenOptions) (enginewebview.Page, err } type pageWrapper struct { - opts enginewebview.OpenOptions + opts enginewebview.OpenOptions + events enginewebview.EventHub + navigationErrors chan error callbackName string readyID string @@ -49,19 +50,23 @@ type pageWrapper struct { url string closed bool - ready chan error - done chan struct{} - loads chan string + ready chan error + done chan struct{} + loads chan string + domReady chan struct{} + sameDocument chan struct{} } 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"` + Event string `json:"event,omitempty"` + SameDocument bool `json:"sameDocument,omitempty"` + 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 { @@ -88,13 +93,16 @@ const startupTimeout = time.Minute 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), + opts: opts, + navigationErrors: make(chan error, 8), + 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), + domReady: make(chan struct{}, 8), + sameDocument: make(chan struct{}, 8), } } @@ -119,6 +127,7 @@ func (p *pageWrapper) start() error { w = webview.New(p.opts.Debug) } p.view = w + w.SetEventHandler(p.handleNativeEvent) w.SetTitle(firstNonEmpty(p.opts.Title, "Gopeed WebView")) w.SetSize(defaultWindowDimension(p.opts.Width, 1280), defaultWindowDimension(p.opts.Height, 800), webview.HintNone) @@ -130,7 +139,6 @@ func (p *pageWrapper) start() error { setter.SetUserAgent(p.opts.UserAgent) } } - w.Init(buildLoadNotifyScript(p.callbackName)) if err := w.Bind(p.callbackName, func(payload string) error { return p.handleCallback(payload) @@ -141,6 +149,7 @@ func (p *pageWrapper) start() error { return } + w.Init(buildLoadNotifyScript(p.callbackName)) applyWindowOptions(w, p.opts) w.SetHtml(buildBootstrapHTML(p.callbackName, p.readyID)) if runtimepkg.GOOS == "linux" { @@ -148,6 +157,7 @@ func (p *pageWrapper) start() error { return } w.Run() + p.handleNativeEvent(webview.Event{Name: "closed"}) w.Destroy() close(p.done) } @@ -176,10 +186,15 @@ func (p *pageWrapper) AddInitScript(script string) error { func (p *pageWrapper) Goto(url string, opts enginewebview.GotoOptions) error { startedAt := time.Now() p.drainLoads() + for { + select { + case <-p.navigationErrors: + continue + default: + } + break + } 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 { @@ -193,7 +208,7 @@ func (p *pageWrapper) Goto(url string, opts enginewebview.GotoOptions) error { if waitUntil == "" { waitUntil = "load" } - err := p.waitForNavigation(url, timeout, waitUntil) + err := p.waitForNavigation(timeout, waitUntil) if err != nil { p.tracef("goto failed %dms url=%s err=%v", time.Since(startedAt).Milliseconds(), url, err) return err @@ -233,7 +248,12 @@ func (p *pageWrapper) Execute(expression string, args ...any) (any, error) { return nil, err } - result := <-ch + var result evalResult + select { + case result = <-ch: + case <-p.done: + return nil, fmt.Errorf("webview page is closed") + } if result.err != nil { p.tracef("execute failed %dms err=%v", time.Since(startedAt).Milliseconds(), result.err) return result.value, result.err @@ -321,6 +341,7 @@ func (p *pageWrapper) Close() error { return nil } p.closed = true + p.failPendingLocked() w := p.view wait := p.done p.mu.Unlock() @@ -343,6 +364,7 @@ func (p *pageWrapper) Close() error { <-terminateDone } <-wait + p.events.Emit(enginewebview.Event{Name: "closed", Data: map[string]any{"reason": "api"}}) return nil } @@ -353,94 +375,29 @@ func (p *pageWrapper) tracef(format string, args ...any) { 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() - +func (p *pageWrapper) waitForNavigation(timeout time.Duration, waitUntil string) error { + timer := time.NewTimer(timeout) + defer timer.Stop() for { select { + case <-p.done: + return fmt.Errorf("webview page is closed") + case err := <-p.navigationErrors: + return err + case <-timer.C: + return fmt.Errorf("webview navigation timeout") + case <-p.sameDocument: + return nil + case <-p.domReady: + if waitUntil == "domcontentloaded" { + return nil + } 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") - } + return nil } } } -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 { @@ -457,7 +414,12 @@ func (p *pageWrapper) dispatch(fn func(w webview.WebView) error) error { w.Dispatch(func() { done <- fn(w) }) - return <-done + select { + case err := <-done: + return err + case <-p.done: + return fmt.Errorf("webview page is closed") + } } func (p *pageWrapper) nativeCookies() ([]nativeCookieJSON, error) { @@ -515,7 +477,31 @@ func (p *pageWrapper) handleCallback(payload string) error { } return nil } + if msg.Event == "domcontentloaded" { + select { + case p.domReady <- struct{}{}: + default: + } + return nil + } + if msg.Event == "url-changed" { + if msg.SameDocument { + select { + case p.sameDocument <- struct{}{}: + default: + } + } + p.mu.Lock() + changed := p.url != msg.URL + p.url = msg.URL + p.mu.Unlock() + if changed { + p.events.Emit(enginewebview.Event{Name: "url-changed", Data: map[string]any{"url": msg.URL, "sameDocument": msg.SameDocument}}) + } + return nil + } if msg.Load { + p.events.Emit(enginewebview.Event{Name: "load", Data: map[string]any{"url": msg.URL}}) select { case p.loads <- msg.URL: default: @@ -540,6 +526,22 @@ func (p *pageWrapper) handleCallback(payload string) error { } func (p *pageWrapper) drainLoads() { + for { + select { + case <-p.domReady: + continue + default: + } + break + } + for { + select { + case <-p.sameDocument: + continue + default: + } + break + } for { select { case <-p.loads: @@ -601,19 +603,74 @@ func buildExecuteScript(callbackName string, reqID string, expression string, ar 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 }); + if (window !== window.top) return; + const send = (message) => { + const cb = globalThis[%s]; + if (typeof cb === "function") cb(JSON.stringify(message)); + }; + let lastURL = String(location.href); + const changed = () => { + const url = String(location.href); + if (url === lastURL) return; + lastURL = url; + send({event: "url-changed", url, sameDocument: true}); + }; + send({event: "url-changed", url: lastURL, sameDocument: false}); + for (const name of ["pushState", "replaceState"]) { + const original = history[name]; + history[name] = function(...args) { + const result = Reflect.apply(original, this, args); + changed(); + return result; + }; + } + window.addEventListener("hashchange", changed); + window.addEventListener("popstate", changed); + window.addEventListener("pageshow", (event) => { + if (event.persisted) send({event: "url-changed", url: String(location.href), sameDocument: false}); + else changed(); + }); + window.addEventListener("DOMContentLoaded", () => send({event: "domcontentloaded"}), {once:true}); + window.addEventListener("load", () => send({load: true, url: String(location.href)}), {once: true}); })();`, string(callbackJSON)) } +func (p *pageWrapper) On(name string, handler func(enginewebview.Event)) (func(), error) { + return p.events.On(name, handler) +} + +func (p *pageWrapper) failPendingLocked() { + for id, ch := range p.pending { + ch <- evalResult{err: fmt.Errorf("webview page is closed")} + delete(p.pending, id) + } +} + +func (p *pageWrapper) handleNativeEvent(event webview.Event) { + switch event.Name { + case "load-error": + p.events.Emit(enginewebview.Event{Name: "load-error", Data: map[string]any{"url": event.URL, "message": event.Message}}) + select { + case p.navigationErrors <- errors.New(event.Message): + default: + } + case "closed": + p.mu.Lock() + api := p.closed + p.closed = true + p.failPendingLocked() + p.mu.Unlock() + reason := "user" + if api { + reason = "api" + } + p.events.Emit(enginewebview.Event{Name: "closed", Data: map[string]any{"reason": reason}}) + if runtimepkg.GOOS == "linux" && !api { + p.view.Dispatch(func() { p.view.Destroy(); close(p.done) }) + } + } +} + func defaultWindowDimension(value int, fallback int) int { if value > 0 { return value diff --git a/internal/webview/goprovider/provider_integration_test.go b/internal/webview/goprovider/provider_integration_test.go index e6d9e63e..aec9a872 100644 --- a/internal/webview/goprovider/provider_integration_test.go +++ b/internal/webview/goprovider/provider_integration_test.go @@ -15,3 +15,5 @@ func TestProviderContract(t *testing.T) { } func TestProviderProfiles(t *testing.T) { integrationtest.RunProfileContract(t, New()) } + +func TestProviderEvents(t *testing.T) { integrationtest.RunEventContract(t, New()) } diff --git a/internal/webview/integrationtest/events.go b/internal/webview/integrationtest/events.go new file mode 100644 index 00000000..5367760b --- /dev/null +++ b/internal/webview/integrationtest/events.go @@ -0,0 +1,111 @@ +package integrationtest + +import ( + "fmt" + "net" + "strings" + "testing" + "time" + + wv "github.com/GopeedLab/gopeed/pkg/download/engine/webview" +) + +// RunEventContract exercises the same public semantics on native and RPC hosts. +func RunEventContract(t *testing.T, provider wv.Provider) { + t.Helper() + page, _, url := openTestPage(t, provider, wv.OpenOptions{Headless: true, Title: "WebView event contract"}) + events := make(chan wv.Event, 100) + for _, name := range []string{"url-changed", "load", "load-error", "closed"} { + if _, err := page.On(name, func(e wv.Event) { events <- e }); err != nil { + t.Fatal(err) + } + } + next := func(name string) wv.Event { + t.Helper() + select { + case event := <-events: + if event.Name != name { + t.Fatalf("got event %s (%v), want %s", event.Name, event.Data, name) + } + return event + case <-time.After(10 * time.Second): + t.Fatalf("timed out waiting for %s", name) + } + return wv.Event{} + } + quiet := func() { + t.Helper() + select { + case e := <-events: + t.Fatalf("unexpected event: %+v", e) + case <-time.After(150 * time.Millisecond): + } + } + quiet() + if err := page.AddInitScript(`globalThis.__initBeforePage = document.readyState`); err != nil { + t.Fatal(err) + } + if err := page.Goto(url, map[string]any{"timeoutMs": 10000}); err != nil { + t.Fatal(err) + } + e := next("url-changed") + if strings.TrimRight(fmt.Sprint(e.Data["url"]), "/") != strings.TrimRight(url, "/") || e.Data["sameDocument"] != false { + t.Fatal(e) + } + next("load") + if result, err := page.Execute(`() => globalThis.__initBeforePage`); err != nil || result != "loading" { + t.Fatalf("init script: %v %v", result, err) + } + if err := page.AddInitScript(`globalThis.__onlyNext = true`); err != nil { + t.Fatal(err) + } + if result, err := page.Execute(`() => typeof globalThis.__onlyNext`); err != nil || result != "undefined" { + t.Fatalf("init ran in current page: %v %v", result, err) + } + for _, expression := range []string{ + `() => { history.pushState({}, '', '#push'); }`, + `() => { history.replaceState({}, '', '#replace'); }`, + `() => { location.hash = 'hash'; }`, + } { + if _, err := page.Execute(expression); err != nil { + t.Fatal(err) + } + e := next("url-changed") + if e.Data["sameDocument"] != true { + t.Fatal(e) + } + quiet() + } + if _, err := page.Execute(`() => { location.reload(); }`); err != nil { + t.Fatal(err) + } + next("load") // Same address on refresh is not a URL change. + if result, err := page.Execute(`() => globalThis.__onlyNext`); err != nil || result != true { + t.Fatalf("next init: %v %v", result, err) + } + if _, err := page.Execute(`() => { const f=document.createElement('iframe'); f.src='/next'; document.body.append(f); }`); err != nil { + t.Fatal(err) + } + quiet() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + failedURL := "http://" + listener.Addr().String() + "/failure" + listener.Close() + if err := page.Goto(failedURL, map[string]any{"timeoutMs": 3000}); err == nil { + t.Fatal("failed navigation succeeded") + } + e = next("load-error") + if e.Data["message"] == "" || !strings.Contains(fmt.Sprint(e.Data["url"]), "/failure") { + t.Fatal(e) + } + if err := page.Close(); err != nil { + t.Fatal(err) + } + e = next("closed") + if e.Data["reason"] != "api" { + t.Fatal(e) + } + quiet() +} diff --git a/internal/webview/rpcprovider/events_test.go b/internal/webview/rpcprovider/events_test.go new file mode 100644 index 00000000..8311e392 --- /dev/null +++ b/internal/webview/rpcprovider/events_test.go @@ -0,0 +1,87 @@ +package rpcprovider + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + wv "github.com/GopeedLab/gopeed/pkg/download/engine/webview" +) + +func TestPageEventStream(t *testing.T) { + events := make(chan wv.Event, 8) + disconnected := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request wv.RPCRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + return + } + if request.Method != "page.events" { + t.Errorf("unexpected method %s", request.Method) + return + } + if r.Header.Get("Authorization") != "Bearer test" { + t.Error("missing authorization") + } + defer close(disconnected) + enc := json.NewEncoder(w) + _ = enc.Encode(map[string]any{"ready": true}) + w.(http.Flusher).Flush() + for { + select { + case e := <-events: + _ = enc.Encode(e) + w.(http.Flusher).Flush() + if e.Name == "closed" { + return + } + case <-r.Context().Done(): + return + } + } + })) + defer server.Close() + p := &page{client: NewClient(wv.RPCConfig{Network: "tcp", Address: strings.TrimPrefix(server.URL, "http://"), Token: "test"}), id: "page-1"} + got := make(chan wv.Event, 8) + off, err := p.On("load", func(e wv.Event) { got <- e }) + if err != nil { + t.Fatal(err) + } + _, err = p.On("closed", func(e wv.Event) { got <- e }) + if err != nil { + t.Fatal(err) + } + events <- wv.Event{Name: "load", Data: map[string]any{"url": "https://example.test"}} + select { + case e := <-got: + if e.Name != "load" { + t.Fatal(e) + } + case <-time.After(time.Second): + t.Fatal("no load event") + } + off() + off() + events <- wv.Event{Name: "load"} + events <- wv.Event{Name: "closed", Data: map[string]any{"reason": "user"}} + select { + case e := <-got: + if e.Name != "closed" || e.Data["reason"] != "user" { + t.Fatal(e) + } + case <-time.After(time.Second): + t.Fatal("no closed event") + } + select { + case <-disconnected: + case <-time.After(time.Second): + t.Fatal("stream retained after close") + } + if _, err = p.On("load", func(wv.Event) {}); err == nil { + t.Fatal("registration after close") + } +} diff --git a/internal/webview/rpcprovider/provider.go b/internal/webview/rpcprovider/provider.go index 607bb5a6..cc4f98ec 100644 --- a/internal/webview/rpcprovider/provider.go +++ b/internal/webview/rpcprovider/provider.go @@ -9,6 +9,8 @@ import ( "net" "net/http" "strings" + "sync" + "time" enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" ) @@ -49,8 +51,13 @@ func (p *Provider) Open(opts enginewebview.OpenOptions) (enginewebview.Page, err } type page struct { - client *Client - id string + eventMu sync.Mutex + events enginewebview.EventHub + eventCancel context.CancelFunc + eventDone chan struct{} + eventErr error + client *Client + id string } func (p *page) AddInitScript(script string) error { @@ -110,14 +117,28 @@ func (p *page) ClearCookies() error { } func (p *page) Close() error { - err := p.client.Call(enginewebview.MethodPageClose, enginewebview.PageCloseParams{ - PageID: p.id, - }, nil) + 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 + err = nil } - return err + if err != nil { + return err + } + p.eventMu.Lock() + cancel, done := p.eventCancel, p.eventDone + p.eventMu.Unlock() + if cancel != nil { + // Drain the terminal notification before cancelling the transport, so + // already-sent navigation events keep their order before closed. + select { + case <-done: + case <-time.After(5 * time.Second): + } + cancel() + } + p.events.Emit(enginewebview.Event{Name: "closed", Data: map[string]any{"reason": "api"}}) + return nil } type Client struct { @@ -203,3 +224,79 @@ func (c *Client) Call(method string, params any, result any) error { func (p *Provider) RemoveProfile(profileID, dataPath string) error { return p.client.Call(enginewebview.MethodProfileRemove, enginewebview.ProfileRemoveParams{ProfileID: profileID}, nil) } + +// On starts one ordered notification stream per page. The ready record is sent +// only after the host has installed its subscription. +func (p *page) On(name string, handler func(enginewebview.Event)) (func(), error) { + p.eventMu.Lock() + defer p.eventMu.Unlock() + if p.eventErr != nil { + return nil, p.eventErr + } + unsubscribe, err := p.events.On(name, handler) + if err != nil { + return nil, err + } + if p.eventCancel != nil { + return unsubscribe, nil + } + ctx, cancel := context.WithCancel(context.Background()) + payload, err := json.Marshal(enginewebview.RPCRequest{Method: enginewebview.MethodPageEvents, Params: enginewebview.PageCloseParams{PageID: p.id}}) + if err != nil { + cancel() + unsubscribe() + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.client.endpoint, bytes.NewReader(payload)) + if err != nil { + cancel() + unsubscribe() + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if p.client.token != "" { + req.Header.Set("Authorization", "Bearer "+p.client.token) + } + // A broken host must not leave registration pending indefinitely. + timer := time.AfterFunc(30*time.Second, cancel) + resp, err := p.client.http.Do(req) + if err != nil { + timer.Stop() + cancel() + unsubscribe() + return nil, err + } + decoder := json.NewDecoder(resp.Body) + var ready struct { + Ready bool `json:"ready"` + } + err = decoder.Decode(&ready) + timer.Stop() + if err != nil || !ready.Ready || resp.StatusCode != http.StatusOK { + resp.Body.Close() + cancel() + unsubscribe() + return nil, fmt.Errorf("webview event stream did not become ready: %v", err) + } + p.eventCancel = cancel + p.eventDone = make(chan struct{}) + go func() { + defer resp.Body.Close() + defer cancel() + defer close(p.eventDone) + for { + var event enginewebview.Event + if err := decoder.Decode(&event); err != nil { + p.eventMu.Lock() + p.eventErr = fmt.Errorf("webview event stream disconnected: %w", err) + p.eventMu.Unlock() + return + } + p.events.Emit(event) + if event.Name == "closed" { + return + } + } + }() + return unsubscribe, nil +} diff --git a/internal/webview/rpcprovider/provider_integration_test.go b/internal/webview/rpcprovider/provider_integration_test.go index 5f26ca56..4efe1c5f 100644 --- a/internal/webview/rpcprovider/provider_integration_test.go +++ b/internal/webview/rpcprovider/provider_integration_test.go @@ -42,3 +42,7 @@ func TestProviderProfiles(t *testing.T) { provider := New(enginewebview.RPCConfig{Network: *webViewRPCNetwork, Address: *webViewRPCAddress, Token: *webViewRPCToken}) integrationtest.RunProfileContract(t, provider) } + +func TestProviderEvents(t *testing.T) { + integrationtest.RunEventContract(t, New(enginewebview.RPCConfig{Network: *webViewRPCNetwork, Address: *webViewRPCAddress, Token: *webViewRPCToken})) +} diff --git a/pkg/download/engine/engine.go b/pkg/download/engine/engine.go index 147f1f98..1ecf0ade 100644 --- a/pkg/download/engine/engine.go +++ b/pkg/download/engine/engine.go @@ -276,3 +276,8 @@ func callExportedFunction(fn func(goja.FunctionCall) goja.Value, args ...goja.Va Arguments: args, }), nil } + +// Post queues a notification without waiting for its callback or returned promise. +func (e *Engine) Post(fn func(*goja.Runtime)) bool { + return e.loop.RunOnLoop(fn) +} diff --git a/pkg/download/engine/webview/events.go b/pkg/download/engine/webview/events.go new file mode 100644 index 00000000..c26b8ed1 --- /dev/null +++ b/pkg/download/engine/webview/events.go @@ -0,0 +1,93 @@ +package webview + +import ( + "fmt" + "sync" +) + +// Event is a main-page notification. Data follows WebviewEventMap. +type Event struct { + Name string `json:"event"` + Data map[string]any `json:"data"` +} + +type EventSource interface { + On(event string, handler func(Event)) (unsubscribe func(), err error) +} + +func ValidEvent(event string) bool { + switch event { + case "url-changed", "load", "load-error", "closed": + return true + } + return false +} + +// EventHub delivers notifications in registration order. Handlers must only +// enqueue work, never block or enter the browser. Closed is terminal. +type EventHub struct { + mu sync.Mutex + seq uint64 + listeners []eventListener + closed bool +} +type eventListener struct { + id uint64 + name string + handler func(Event) +} + +func (h *EventHub) On(name string, handler func(Event)) (func(), error) { + if !ValidEvent(name) || handler == nil { + return nil, fmt.Errorf("invalid webview event or handler: %s", name) + } + h.mu.Lock() + defer h.mu.Unlock() + if h.closed { + return nil, fmt.Errorf("webview page is closed") + } + h.seq++ + id := h.seq + h.listeners = append(h.listeners, eventListener{id, name, handler}) + return func() { + h.mu.Lock() + defer h.mu.Unlock() + for i, listener := range h.listeners { + if listener.id == id { + h.listeners = append(h.listeners[:i], h.listeners[i+1:]...) + break + } + } + }, nil +} + +func (h *EventHub) Emit(event Event) { + h.mu.Lock() + if h.closed { + h.mu.Unlock() + return + } + listeners := append([]eventListener(nil), h.listeners...) + if event.Name == "closed" { + h.closed = true + h.listeners = nil + } + h.mu.Unlock() + for _, listener := range listeners { + if listener.name == event.Name { + listener.handler(event) + } + } +} + +func (p *PageHandle) On(name string, handler func(Event)) (func(), error) { + page, err := p.page() + if err != nil { + return nil, err + } + source, ok := page.(EventSource) + if !ok { + return nil, fmt.Errorf("WebView provider does not support events") + } + return source.On(name, handler) +} diff --git a/pkg/download/engine/webview/events_test.go b/pkg/download/engine/webview/events_test.go new file mode 100644 index 00000000..087913ab --- /dev/null +++ b/pkg/download/engine/webview/events_test.go @@ -0,0 +1,52 @@ +package webview + +import ( + "reflect" + "sync" + "testing" +) + +func TestEventHubLifecycle(t *testing.T) { + var hub EventHub + var got []string + hub.Emit(Event{Name: "load"}) // Never replay pre-registration events. + off, err := hub.On("load", func(Event) { got = append(got, "first") }) + if err != nil { + t.Fatal(err) + } + _, _ = hub.On("load", func(Event) { got = append(got, "second") }) + _, _ = hub.On("closed", func(e Event) { + got = append(got, e.Data["reason"].(string)) + off() // Unsubscription from a callback must not deadlock. + }) + hub.Emit(Event{Name: "load"}) + off() + off() + hub.Emit(Event{Name: "load"}) + hub.Emit(Event{Name: "closed", Data: map[string]any{"reason": "api"}}) + hub.Emit(Event{Name: "load"}) + hub.Emit(Event{Name: "closed"}) + if !reflect.DeepEqual(got, []string{"first", "second", "second", "api"}) { + t.Fatal(got) + } + if len(hub.listeners) != 0 { + t.Fatal("closed page retained listeners") + } + if _, err := hub.On("load", func(Event) {}); err == nil { + t.Fatal("registration on closed page succeeded") + } +} + +func TestEventHubConcurrentUnsubscribe(t *testing.T) { + var hub EventHub + off, _ := hub.On("load", func(Event) {}) + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { defer wg.Done(); off(); hub.Emit(Event{Name: "load"}) }() + } + wg.Wait() + if _, err := hub.On("invalid", func(Event) {}); err == nil { + t.Fatal("accepted invalid event") + } +} diff --git a/pkg/download/engine/webview/rpc.go b/pkg/download/engine/webview/rpc.go index 77c91976..8da91cec 100644 --- a/pkg/download/engine/webview/rpc.go +++ b/pkg/download/engine/webview/rpc.go @@ -8,6 +8,7 @@ const ( MethodProfileRemove = "profile.remove" MethodIsAvailable = "webview.isAvailable" MethodPageOpen = "page.open" + MethodPageEvents = "page.events" MethodPageAddInitScript = "page.addInitScript" MethodPageGoto = "page.goto" MethodPageExecute = "page.execute" diff --git a/pkg/download/extension.go b/pkg/download/extension.go index 53f9e989..83bcdd9c 100644 --- a/pkg/download/extension.go +++ b/pkg/download/extension.go @@ -376,7 +376,7 @@ func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, c gopeed.Runtime = &InstanceRuntime{ WebView: d.newExtensionWebViewRuntime(session, ext.buildIdentity()), } - err = injectGopeed(engine.Runtime, gopeed) + err = injectGopeed(engine.Runtime, gopeed, engine.Post) if err != nil { gopeed.Logger.logger.Error().Err(err).Msgf("[%s] engine inject failed", ext.buildIdentity()) return @@ -651,20 +651,22 @@ func (h InstanceEvents) OnDone(fn engine.JSFunction) { } type ExtensionInfo struct { - Identity string `json:"identity"` - Name string `json:"name"` - Author string `json:"author"` - Title string `json:"title"` - Version string `json:"version"` + HostVersion string `json:"hostVersion"` + Identity string `json:"identity"` + Name string `json:"name"` + Author string `json:"author"` + Title string `json:"title"` + Version string `json:"version"` } func NewExtensionInfo(ext *Extension) *ExtensionInfo { return &ExtensionInfo{ - Identity: ext.buildIdentity(), - Name: ext.Name, - Author: ext.Author, - Title: ext.Title, - Version: ext.Version, + HostVersion: base.Version, + Identity: ext.buildIdentity(), + Name: ext.Name, + Author: ext.Author, + Title: ext.Title, + Version: ext.Version, } } diff --git a/pkg/download/extension_engine.go b/pkg/download/extension_engine.go index 4ef05d23..5a1ce926 100644 --- a/pkg/download/extension_engine.go +++ b/pkg/download/extension_engine.go @@ -33,7 +33,7 @@ func (d *Downloader) NewExtensionEngine(ext *Extension, settings map[string]any) WebView: d.newExtensionWebViewRuntime(session, ext.buildIdentity()), }, } - if err := injectGopeed(engine.Runtime, gopeed); err != nil { + if err := injectGopeed(engine.Runtime, gopeed, engine.Post); err != nil { session.CloseIfIdle() return nil, err } diff --git a/pkg/download/extension_runtime_events_test.go b/pkg/download/extension_runtime_events_test.go new file mode 100644 index 00000000..6d5509bd --- /dev/null +++ b/pkg/download/extension_runtime_events_test.go @@ -0,0 +1,117 @@ +package download + +import ( + "fmt" + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/download/engine" + wv "github.com/GopeedLab/gopeed/pkg/download/engine/webview" + "testing" + "time" +) + +type eventTestPage struct { + fakeRuntimeWebViewPage + wv.EventHub +} + +func (p *eventTestPage) Close() error { + p.Emit(wv.Event{Name: "closed", Data: map[string]any{"reason": "api"}}) + return nil +} + +type eventTestOpener struct{ page wv.Page } + +func (o eventTestOpener) Open(wv.OpenOptions) (wv.Page, error) { return o.page, nil } + +func TestExtensionInfoHostVersion(t *testing.T) { + e := engine.NewEngine(nil) + defer e.Close() + info := NewExtensionInfo(&Extension{Name: "sample", Author: "test", Version: "1.2.3"}) + if err := injectGopeed(e.Runtime, &Instance{Info: info}, e.Post); err != nil { + t.Fatal(err) + } + result, err := e.RunString(`gopeed.info.hostVersion + "/" + gopeed.info.version`) + if err != nil || result != base.Version+"/1.2.3" { + t.Fatalf("%v %v", result, err) + } +} + +func TestExtensionWebViewEvents(t *testing.T) { + e := engine.NewEngine(nil) + defer e.Close() + page := &eventTestPage{} + runtime := wv.NewRuntime(eventTestOpener{page}, true) + defer runtime.Close() + if err := injectGopeed(e.Runtime, &Instance{Runtime: &InstanceRuntime{WebView: runtime}}, e.Post); err != nil { + t.Fatal(err) + } + _, err := e.RunString(`var page = gopeed.runtime.webview.open(); var seen = []; var off; + (async () => { + const registered = page.on("load", () => { seen.push("load"); return new Promise(() => {}); }); + if (!(registered instanceof Promise)) throw new Error("on must return a Promise"); + off = await registered; + await page.on("closed", data => seen.push(data.reason)); + for (const args of [["invalid", () => {}], ["load", 1]]) { + let rejected = false; + try { await page.on(...args); } catch (_) { rejected = true; } + if (!rejected) throw new Error("invalid listener accepted"); + } + })()`) + if err != nil { + t.Fatal(err) + } + page.Emit(wv.Event{Name: "load", Data: map[string]any{"url": "https://example.test"}}) + result, err := e.RunString(`off(); off(); seen.join(",")`) + if err != nil || result != "load" { + t.Fatalf("%v %v", result, err) + } + page.Emit(wv.Event{Name: "load"}) + if _, err = e.RunString(`page.close()`); err != nil { + t.Fatal(err) + } + result, err = e.RunString(`seen.join(",")`) + if err != nil || result != "load,api" { + t.Fatalf("%v %v", result, err) + } +} + +// Navigation must not occupy the Goja loop: a notification can execute page +// operations even while the original goto promise is still pending. +type navigationEventTestPage struct { + eventTestPage + release chan struct{} +} + +func (p *navigationEventTestPage) Goto(url string, _ wv.GotoOptions) error { + p.Emit(wv.Event{Name: "load", Data: map[string]any{"url": url}}) + select { + case <-p.release: + return nil + case <-time.After(2 * time.Second): + return fmt.Errorf("event callback blocked by navigation") + } +} +func (p *navigationEventTestPage) Execute(string, ...any) (any, error) { + close(p.release) + return nil, nil +} +func TestExtensionWebViewEventsDuringNavigation(t *testing.T) { + e := engine.NewEngine(nil) + defer e.Close() + page := &navigationEventTestPage{release: make(chan struct{})} + runtime := wv.NewRuntime(eventTestOpener{page}, true) + defer runtime.Close() + if err := injectGopeed(e.Runtime, &Instance{Runtime: &InstanceRuntime{WebView: runtime}}, e.Post); err != nil { + t.Fatal(err) + } + result, err := e.RunString(`(async () => { + const page = await gopeed.runtime.webview.open(); + await page.on("load", () => page.execute("release")); + if (await page.addInitScript("globalThis.ready = true") !== undefined) throw new Error("init result must be void"); + if (await page.goto("https://example.test") !== undefined) throw new Error("goto result must be void"); + return true; + })()`) + if err != nil || result != true { + t.Fatalf("%v %v", result, err) + } +} diff --git a/pkg/download/extension_runtime_webview.go b/pkg/download/extension_runtime_webview.go index 25d07cad..32c22e60 100644 --- a/pkg/download/extension_runtime_webview.go +++ b/pkg/download/extension_runtime_webview.go @@ -8,7 +8,7 @@ import ( "github.com/dop251/goja" ) -func injectGopeed(vm *goja.Runtime, gopeed *Instance) error { +func injectGopeed(vm *goja.Runtime, gopeed *Instance, post func(func(*goja.Runtime)) bool) error { gopeedObject := vm.NewObject() if gopeed == nil { return vm.Set("gopeed", gopeedObject) @@ -37,7 +37,7 @@ func injectGopeed(vm *goja.Runtime, gopeed *Instance) error { return err } if gopeed.Runtime.WebView != nil { - if err := runtimeObject.Set("webview", newJSWebViewRuntime(vm, gopeed.Runtime.WebView)); err != nil { + if err := runtimeObject.Set("webview", newJSWebViewRuntime(vm, gopeed.Runtime.WebView, post)); err != nil { return err } } @@ -103,7 +103,7 @@ func newJSEventsRuntime(vm *goja.Runtime, events InstanceEvents) *goja.Object { return obj } -func newJSWebViewRuntime(vm *goja.Runtime, runtime *enginewebview.Runtime) *goja.Object { +func newJSWebViewRuntime(vm *goja.Runtime, runtime *enginewebview.Runtime, post func(func(*goja.Runtime)) bool) *goja.Object { obj := vm.NewObject() _ = obj.Set("isAvailable", func(goja.FunctionCall) goja.Value { return vm.ToValue(runtime.IsAvailable()) @@ -113,35 +113,48 @@ func newJSWebViewRuntime(vm *goja.Runtime, runtime *enginewebview.Runtime) *goja if err != nil { panic(vm.ToValue(err)) } - return newJSWebViewPage(vm, page) + return newJSWebViewPage(vm, page, post) }) return obj } -func newJSWebViewPage(vm *goja.Runtime, page *enginewebview.PageHandle) *goja.Object { +func newJSWebViewPage(vm *goja.Runtime, page *enginewebview.PageHandle, post func(func(*goja.Runtime)) bool) *goja.Object { obj := vm.NewObject() + _ = obj.Set("on", func(call goja.FunctionCall) goja.Value { + promise, resolve, reject := vm.NewPromise() + handler, ok := goja.AssertFunction(call.Argument(1)) + if !ok { + _ = reject(vm.NewTypeError("handler must be a function")) + return vm.ToValue(promise) + } + unsubscribe, err := page.On(call.Argument(0).String(), func(event enginewebview.Event) { + post(func(vm *goja.Runtime) { + // A callback is a notification: never await its returned promise. + _, _ = handler(goja.Undefined(), vm.ToValue(event.Data)) + }) + }) + if err != nil { + _ = reject(vm.NewGoError(err)) + } else { + _ = resolve(func(goja.FunctionCall) goja.Value { unsubscribe(); return goja.Undefined() }) + } + return vm.ToValue(promise) + }) + _ = 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() + return webviewPromise(vm, post, func() (any, error) { return nil, page.AddInitScript(script) }) }) _ = 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() + opts := optionalMap(call.Argument(1)) + return webviewPromise(vm, post, func() (any, error) { return nil, page.Goto(url, opts) }) }) _ = obj.Set("execute", func(call goja.FunctionCall) goja.Value { expression, err := enginewebview.NormalizeExecutableValue(call.Argument(0)) @@ -254,10 +267,7 @@ func newJSWebViewPage(vm *goja.Runtime, page *enginewebview.PageHandle) *goja.Ob 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 webviewPromise(vm, post, func() (any, error) { return nil, page.Close() }) }) return obj } @@ -290,3 +300,22 @@ func requireStringArg(call goja.FunctionCall, index int, name string) (string, e } return value.String(), nil } + +// Browser operations run outside Goja, allowing notifications to be delivered +// while navigation is pending. Only the event loop touches JavaScript values. +func webviewPromise(vm *goja.Runtime, post func(func(*goja.Runtime)) bool, work func() (any, error)) goja.Value { + promise, resolve, reject := vm.NewPromise() + go func() { + value, err := work() + post(func(vm *goja.Runtime) { + if err != nil { + _ = reject(vm.NewGoError(err)) + } else if value == nil { + _ = resolve(goja.Undefined()) + } else { + _ = resolve(value) + } + }) + }() + return vm.ToValue(promise) +} diff --git a/pkg/download/extension_webview_config.go b/pkg/download/extension_webview_config.go index 164df1e4..75f295ab 100644 --- a/pkg/download/extension_webview_config.go +++ b/pkg/download/extension_webview_config.go @@ -181,3 +181,11 @@ func (d *Downloader) removeExtensionWebViewProfile(identity string) error { } return os.RemoveAll(path) } + +func (p *extensionWebViewPage) On(event string, handler func(enginewebview.Event)) (func(), error) { + source, ok := p.Page.(enginewebview.EventSource) + if !ok { + return nil, fmt.Errorf("WebView provider does not support events") + } + return source.On(event, handler) +} diff --git a/ui/flutter/lib/app/rpc/webview_event_script.dart b/ui/flutter/lib/app/rpc/webview_event_script.dart new file mode 100644 index 00000000..94c4ff95 --- /dev/null +++ b/ui/flutter/lib/app/rpc/webview_event_script.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; + +/// Runs before page scripts and reports only the main document's events. +String buildWebViewEventScript(String channel) => + ''' +(() => { + if (window !== window.top) return; + const pending = []; + const flush = () => { + const bridge = window.flutter_inappwebview; + if (!bridge || typeof bridge.callHandler !== 'function') return; + while (pending.length) bridge.callHandler(${jsonEncode(channel)}, JSON.stringify(pending.shift())); + }; + const send = (event, data) => { pending.push({event, data}); flush(); }; + window.addEventListener('flutterInAppWebViewPlatformReady', flush); + let lastURL = String(location.href); + const changed = () => { + const url = String(location.href); + if (url === lastURL) return; + lastURL = url; + send('url-changed', {url, sameDocument: true}); + }; + send('url-changed', {url: lastURL, sameDocument: false}); + for (const name of ['pushState', 'replaceState']) { + const original = history[name]; + history[name] = function(...args) { + const result = Reflect.apply(original, this, args); + changed(); + return result; + }; + } + window.addEventListener('hashchange', changed); + window.addEventListener('popstate', changed); + window.addEventListener('pageshow', (event) => { + if (event.persisted) send('url-changed', {url: String(location.href), sameDocument: false}); + else changed(); + }); + window.addEventListener('load', () => send('load', {url: String(location.href)}), {once: true}); +})(); +'''; diff --git a/ui/flutter/lib/app/rpc/webview_rpc_overlay.dart b/ui/flutter/lib/app/rpc/webview_rpc_overlay.dart index 97ae07cd..ecb75f7e 100644 --- a/ui/flutter/lib/app/rpc/webview_rpc_overlay.dart +++ b/ui/flutter/lib/app/rpc/webview_rpc_overlay.dart @@ -1,4 +1,7 @@ -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' show IconButton; +import 'package:flutter/material.dart' hide IconButton; +import '../../l10n/l10n.dart'; +import '../../shared/widgets/app_tooltip.dart'; import 'webview_rpc_service.dart'; @@ -70,11 +73,24 @@ class _WebViewRpcPageViewState extends State<_WebViewRpcPageView> { 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, + child: Row( + children: [ + Expanded( + child: Text( + page.title.isNotEmpty ? page.title : 'WebView', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + AppTooltip( + message: context.l10n.close, + child: IconButton.ghost( + onPressed: () => WebViewRpcService.instance.closePageByUser(page.pageId), + icon: const Icon(Icons.close), + ), + ), + ], ), ), 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 index b138abd0..36186f47 100644 --- a/ui/flutter/lib/app/rpc/webview_rpc_service.dart +++ b/ui/flutter/lib/app/rpc/webview_rpc_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -10,6 +11,7 @@ import '../../core/common/start_config.dart'; import '../../util/log_util.dart'; import '../../util/util.dart'; import 'server.dart'; +import 'webview_event_script.dart'; String buildWebViewExecuteScript({ required String channelName, @@ -117,6 +119,38 @@ class WebViewRpcService { final method = body['method'] as String? ?? ''; final params = (body['params'] as Map?)?.cast() ?? const {}; try { + if (method == 'page.events') { + final page = _page(params); + ctx.response.headers.contentType = ContentType('application', 'x-ndjson'); + ctx.response.bufferOutput = false; + final output = StreamController>(); + void send(Map record) { + if (!output.isClosed) output.add(utf8.encode('${jsonEncode(record)}\n')); + } + + final subscription = page.events.listen( + send, + onDone: () { + unawaited(output.close()); + }, + ); + send({'ready': true}); + final sending = ctx.response.addStream(output.stream); + try { + await Future.any([sending, ctx.response.done]); + } catch (_) { + // A disconnected listener cannot receive a JSON error response. + } finally { + await subscription.cancel(); + unawaited(output.close()); + try { + await sending; + } catch (_) { + // The response may have ended before the page did. + } + } + return; + } final result = await _dispatch(method, params); await ctx.writeJSON({'result': result, 'error': null}); } catch (e, stackTrace) { @@ -261,13 +295,15 @@ class WebViewRpcService { throw WebViewRpcException(code: 'INVALID_REQUEST', message: 'missing or invalid "cookie"'); } - Future _closePage(String pageId) async { + Future closePageByUser(String pageId) => _closePage(pageId, reason: 'user'); + + Future _closePage(String pageId, {String reason = 'api'}) async { final page = _pagesById.remove(pageId); if (page == null) { throw WebViewRpcException(code: 'PAGE_NOT_FOUND', message: 'page not found'); } _syncPages(); - await page.dispose(); + await page.dispose(reason: reason); } void _syncPages() { @@ -309,6 +345,12 @@ class WebViewRpcPageSession { required this.userAgent, }); + final StreamController> _events = StreamController.broadcast(sync: true); + Stream> get events => _events.stream; + void _emit(String event, Map data) { + if (!_disposed) _events.add({'event': event, 'data': data}); + } + final String pageId; final WebViewProfile profile; late final CookieManager cookieManager = profile.cookieManager; @@ -328,11 +370,19 @@ class WebViewRpcPageSession { InAppWebViewController? _controller; Completer? _navigation; String _currentUrl = ''; + String _eventUrl = ''; int _executeSeq = 0; bool _disposed = false; Future init() async { callbackChannelName = '__gopeedWebViewCallback_${pageId.replaceAll('-', '_')}'; + _initScripts.add( + UserScript( + source: buildWebViewEventScript(callbackChannelName), + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + forMainFrameOnly: true, + ), + ); if (headless) { _headlessWebView = HeadlessInAppWebView( initialSize: Size(width > 0 ? width.toDouble() : 1, height > 0 ? height.toDouble() : 1), @@ -354,14 +404,6 @@ class WebViewRpcPageSession { _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 { @@ -507,8 +549,11 @@ class WebViewRpcPageSession { await cookieManager.deleteAllCookies(); } - Future dispose() async { + Future dispose({String reason = 'api'}) async { + if (_disposed) return; + _emit('closed', {'reason': reason}); _disposed = true; + unawaited(_events.close()); if (!_ready.isCompleted) { _ready.completeError(WebViewRpcException(code: 'PAGE_NOT_FOUND', message: 'webview page closed')); } @@ -587,6 +632,8 @@ class WebViewRpcPageSession { } void _handleReceivedError(InAppWebViewController controller, WebResourceRequest request, WebResourceError error) { + if (request.isForMainFrame != true) return; + _emit('load-error', {'url': request.url.toString(), 'message': error.description}); _completeNavigationError(WebViewRpcException(code: 'NAVIGATION_FAILED', message: error.description)); } @@ -595,6 +642,11 @@ class WebViewRpcPageSession { WebResourceRequest request, WebResourceResponse errorResponse, ) { + if (request.isForMainFrame != true) return; + _emit('load-error', { + 'url': request.url.toString(), + 'message': 'HTTP ${errorResponse.statusCode}: ${errorResponse.reasonPhrase ?? 'navigation failed'}', + }); _completeNavigationError( WebViewRpcException( code: 'NAVIGATION_FAILED', @@ -651,6 +703,18 @@ class WebViewRpcPageSession { return; } final message = decoded.cast(); + final event = message['event']; + if (event == 'url-changed' || event == 'load') { + final data = (message['data'] as Map).cast(); + final url = data['url'] as String; + if (event == 'load' || url != _eventUrl) { + _eventUrl = url; + _currentUrl = url; + _emit(event as String, data); + if (event == 'url-changed' && data['sameDocument'] == true) _completeNavigation(); + } + return; + } final id = message['id']?.toString(); if (id == null || id.isEmpty) { return; diff --git a/ui/flutter/test/app/webview_event_session_test.dart b/ui/flutter/test/app/webview_event_session_test.dart new file mode 100644 index 00000000..2f877b9b --- /dev/null +++ b/ui/flutter/test/app/webview_event_session_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:gopeed/app/rpc/webview_rpc_service.dart'; + +class _Profile extends Fake implements WebViewProfile {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('closed emits reason once and closes listener stream', () async { + final page = WebViewRpcPageSession( + pageId: 'test', + profile: _Profile(), + headless: true, + debug: false, + title: '', + width: 1, + height: 1, + userAgent: '', + ); + // Consume the expected ready failure when an unopened page is disposed. + final pending = page.execute('1', []); + final failure = expectLater(pending, throwsA(isA())); + final events = page.events.toList(); + await page.dispose(reason: 'user'); + await page.dispose(reason: 'api'); + await failure; + expect(await events, [ + { + 'event': 'closed', + 'data': {'reason': 'user'}, + }, + ]); + }); +} diff --git a/ui/flutter_old/lib/app/rpc/webview_event_script.dart b/ui/flutter_old/lib/app/rpc/webview_event_script.dart new file mode 100644 index 00000000..6ef8557a --- /dev/null +++ b/ui/flutter_old/lib/app/rpc/webview_event_script.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; + +/// Runs before page scripts and reports only the main document's events. +String buildWebViewEventScript(String channel) => ''' +(() => { + if (window !== window.top) return; + const pending = []; + const flush = () => { + const bridge = window.flutter_inappwebview; + if (!bridge || typeof bridge.callHandler !== 'function') return; + while (pending.length) bridge.callHandler(${jsonEncode(channel)}, JSON.stringify(pending.shift())); + }; + const send = (event, data) => { pending.push({event, data}); flush(); }; + window.addEventListener('flutterInAppWebViewPlatformReady', flush); + let lastURL = String(location.href); + const changed = () => { + const url = String(location.href); + if (url === lastURL) return; + lastURL = url; + send('url-changed', {url, sameDocument: true}); + }; + send('url-changed', {url: lastURL, sameDocument: false}); + for (const name of ['pushState', 'replaceState']) { + const original = history[name]; + history[name] = function(...args) { + const result = Reflect.apply(original, this, args); + changed(); + return result; + }; + } + window.addEventListener('hashchange', changed); + window.addEventListener('popstate', changed); + window.addEventListener('pageshow', (event) => { + if (event.persisted) send('url-changed', {url: String(location.href), sameDocument: false}); + else changed(); + }); + window.addEventListener('load', () => send('load', {url: String(location.href)}), {once: true}); +})(); +'''; diff --git a/ui/flutter_old/lib/app/rpc/webview_rpc_overlay.dart b/ui/flutter_old/lib/app/rpc/webview_rpc_overlay.dart index f3943d71..7ceda5d1 100644 --- a/ui/flutter_old/lib/app/rpc/webview_rpc_overlay.dart +++ b/ui/flutter_old/lib/app/rpc/webview_rpc_overlay.dart @@ -79,12 +79,19 @@ class _WebViewRpcPageViewState extends State<_WebViewRpcPageView> { 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, - ), + child: Row(children: [ + Expanded( + child: Text( + page.title.isNotEmpty ? page.title : 'WebView', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + )), + IconButton( + onPressed: () => WebViewRpcService.instance + .closePageByUser(page.pageId), + icon: const Icon(Icons.close)) + ]), ), Expanded(child: content), ], diff --git a/ui/flutter_old/lib/app/rpc/webview_rpc_service.dart b/ui/flutter_old/lib/app/rpc/webview_rpc_service.dart index 486ddae8..9c3bf88c 100644 --- a/ui/flutter_old/lib/app/rpc/webview_rpc_service.dart +++ b/ui/flutter_old/lib/app/rpc/webview_rpc_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; @@ -9,6 +10,7 @@ import '../../core/common/start_config.dart'; import '../../util/log_util.dart'; import '../../util/util.dart'; import 'server.dart'; +import 'webview_event_script.dart'; String buildWebViewExecuteScript({ required String channelName, @@ -124,6 +126,38 @@ class WebViewRpcService { final params = (body['params'] as Map?)?.cast() ?? const {}; try { + if (method == 'page.events') { + final page = _page(params); + ctx.response.headers.contentType = + ContentType('application', 'x-ndjson'); + ctx.response.bufferOutput = false; + final output = StreamController>(); + void send(Map record) { + if (!output.isClosed) { + output.add(utf8.encode('${jsonEncode(record)}\n')); + } + } + + final subscription = page.events.listen(send, onDone: () { + unawaited(output.close()); + }); + send({'ready': true}); + final sending = ctx.response.addStream(output.stream); + try { + await Future.any([sending, ctx.response.done]); + } catch (_) { + // A disconnected listener cannot receive a JSON error response. + } finally { + await subscription.cancel(); + unawaited(output.close()); + try { + await sending; + } catch (_) { + // The response may have ended before the page did. + } + } + return; + } final result = await _dispatch(method, params); await ctx.writeJSON({ 'result': result, @@ -284,7 +318,10 @@ class WebViewRpcService { ); } - Future _closePage(String pageId) async { + Future closePageByUser(String pageId) => + _closePage(pageId, reason: 'user'); + + Future _closePage(String pageId, {String reason = 'api'}) async { final page = _pagesById.remove(pageId); if (page == null) { throw WebViewRpcException( @@ -293,7 +330,7 @@ class WebViewRpcService { ); } _syncPages(); - await page.dispose(); + await page.dispose(reason: reason); } void _syncPages() { @@ -340,6 +377,13 @@ class WebViewRpcPageSession { required this.userAgent, }); + final StreamController> _events = + StreamController.broadcast(sync: true); + Stream> get events => _events.stream; + void _emit(String event, Map data) { + if (!_disposed) _events.add({'event': event, 'data': data}); + } + final String pageId; final CookieManager cookieManager; final bool headless; @@ -359,12 +403,18 @@ class WebViewRpcPageSession { InAppWebViewController? _controller; Completer? _navigation; String _currentUrl = ''; + String _eventUrl = ''; int _executeSeq = 0; bool _disposed = false; Future init() async { callbackChannelName = '__gopeedWebViewCallback_${pageId.replaceAll('-', '_')}'; + _initScripts.add(UserScript( + source: buildWebViewEventScript(callbackChannelName), + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + forMainFrameOnly: true, + )); if (headless) { _headlessWebView = HeadlessInAppWebView( initialSize: Size( @@ -392,14 +442,6 @@ class WebViewRpcPageSession { _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 { @@ -564,8 +606,11 @@ class WebViewRpcPageSession { await cookieManager.deleteAllCookies(); } - Future dispose() async { + Future dispose({String reason = 'api'}) async { + if (_disposed) return; + _emit('closed', {'reason': reason}); _disposed = true; + unawaited(_events.close()); if (!_ready.isCompleted) { _ready.completeError( WebViewRpcException( @@ -668,6 +713,9 @@ class WebViewRpcPageSession { WebResourceRequest request, WebResourceError error, ) { + if (request.isForMainFrame != true) return; + _emit('load-error', + {'url': request.url.toString(), 'message': error.description}); _completeNavigationError( WebViewRpcException( code: 'NAVIGATION_FAILED', @@ -681,6 +729,12 @@ class WebViewRpcPageSession { WebResourceRequest request, WebResourceResponse errorResponse, ) { + if (request.isForMainFrame != true) return; + _emit('load-error', { + 'url': request.url.toString(), + 'message': + 'HTTP ${errorResponse.statusCode}: ${errorResponse.reasonPhrase ?? 'navigation failed'}' + }); _completeNavigationError( WebViewRpcException( code: 'NAVIGATION_FAILED', @@ -741,6 +795,20 @@ class WebViewRpcPageSession { return; } final message = decoded.cast(); + final event = message['event']; + if (event == 'url-changed' || event == 'load') { + final data = (message['data'] as Map).cast(); + final url = data['url'] as String; + if (event == 'load' || url != _eventUrl) { + _eventUrl = url; + _currentUrl = url; + _emit(event as String, data); + if (event == 'url-changed' && data['sameDocument'] == true) { + _completeNavigation(); + } + } + return; + } final id = message['id']?.toString(); if (id == null || id.isEmpty) { return;