feat(extension): expose host version and WebView page events

This commit is contained in:
Levi committed 2026-09-17 23:43:39 +08:00
1 parent 7c1447f659
commit dfe8e9a3fd
27 files changed
+1230 -191

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ go 1.25.4
require ( require (
codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0 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/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
github.com/bodgit/sevenzip v1.6.1 github.com/bodgit/sevenzip v1.6.1
+2
View File
@@ -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/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 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-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 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= 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= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+44
View File
@@ -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. 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 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. 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`
+48
View File
@@ -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<T = unknown> =
| string
| ((...args: unknown[]) => T | Promise<T>);
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<K extends keyof WebviewEventMap>(
event: K,
handler: (data: WebviewEventMap[K]) => void,
): Promise<WebviewUnsubscribe>;
/** Runs at document creation on future navigations, never immediately. */
addInitScript(script: string): Promise<void>;
goto(url: string, opts?: WebviewGotoOptions): Promise<void>;
}
@@ -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()) }
+174 -117
View File
@@ -6,7 +6,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/url"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -37,7 +36,9 @@ func (p *Provider) Open(opts enginewebview.OpenOptions) (enginewebview.Page, err
} }
type pageWrapper struct { type pageWrapper struct {
opts enginewebview.OpenOptions opts enginewebview.OpenOptions
events enginewebview.EventHub
navigationErrors chan error
callbackName string callbackName string
readyID string readyID string
@@ -49,19 +50,23 @@ type pageWrapper struct {
url string url string
closed bool closed bool
ready chan error ready chan error
done chan struct{} done chan struct{}
loads chan string loads chan string
domReady chan struct{}
sameDocument chan struct{}
} }
type evalMessage struct { type evalMessage struct {
ID string `json:"id"` Event string `json:"event,omitempty"`
Ready bool `json:"ready,omitempty"` SameDocument bool `json:"sameDocument,omitempty"`
Load bool `json:"load,omitempty"` ID string `json:"id"`
URL string `json:"url,omitempty"` Ready bool `json:"ready,omitempty"`
OK bool `json:"ok,omitempty"` Load bool `json:"load,omitempty"`
Value any `json:"value,omitempty"` URL string `json:"url,omitempty"`
Error string `json:"error,omitempty"` OK bool `json:"ok,omitempty"`
Value any `json:"value,omitempty"`
Error string `json:"error,omitempty"`
} }
type evalResult struct { type evalResult struct {
@@ -88,13 +93,16 @@ const startupTimeout = time.Minute
func newPageWrapper(opts enginewebview.OpenOptions) *pageWrapper { func newPageWrapper(opts enginewebview.OpenOptions) *pageWrapper {
seq := providerSeq.Add(1) seq := providerSeq.Add(1)
return &pageWrapper{ return &pageWrapper{
opts: opts, opts: opts,
callbackName: fmt.Sprintf("__gopeedWebViewCallback_%d", seq), navigationErrors: make(chan error, 8),
readyID: fmt.Sprintf("__ready__%d", seq), callbackName: fmt.Sprintf("__gopeedWebViewCallback_%d", seq),
pending: make(map[string]chan evalResult), readyID: fmt.Sprintf("__ready__%d", seq),
ready: make(chan error, 1), pending: make(map[string]chan evalResult),
done: make(chan struct{}), ready: make(chan error, 1),
loads: make(chan string, 8), 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) w = webview.New(p.opts.Debug)
} }
p.view = w p.view = w
w.SetEventHandler(p.handleNativeEvent)
w.SetTitle(firstNonEmpty(p.opts.Title, "Gopeed WebView")) w.SetTitle(firstNonEmpty(p.opts.Title, "Gopeed WebView"))
w.SetSize(defaultWindowDimension(p.opts.Width, 1280), defaultWindowDimension(p.opts.Height, 800), webview.HintNone) 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) setter.SetUserAgent(p.opts.UserAgent)
} }
} }
w.Init(buildLoadNotifyScript(p.callbackName))
if err := w.Bind(p.callbackName, func(payload string) error { if err := w.Bind(p.callbackName, func(payload string) error {
return p.handleCallback(payload) return p.handleCallback(payload)
@@ -141,6 +149,7 @@ func (p *pageWrapper) start() error {
return return
} }
w.Init(buildLoadNotifyScript(p.callbackName))
applyWindowOptions(w, p.opts) applyWindowOptions(w, p.opts)
w.SetHtml(buildBootstrapHTML(p.callbackName, p.readyID)) w.SetHtml(buildBootstrapHTML(p.callbackName, p.readyID))
if runtimepkg.GOOS == "linux" { if runtimepkg.GOOS == "linux" {
@@ -148,6 +157,7 @@ func (p *pageWrapper) start() error {
return return
} }
w.Run() w.Run()
p.handleNativeEvent(webview.Event{Name: "closed"})
w.Destroy() w.Destroy()
close(p.done) close(p.done)
} }
@@ -176,10 +186,15 @@ func (p *pageWrapper) AddInitScript(script string) error {
func (p *pageWrapper) Goto(url string, opts enginewebview.GotoOptions) error { func (p *pageWrapper) Goto(url string, opts enginewebview.GotoOptions) error {
startedAt := time.Now() startedAt := time.Now()
p.drainLoads() p.drainLoads()
for {
select {
case <-p.navigationErrors:
continue
default:
}
break
}
if err := p.dispatch(func(w webview.WebView) error { if err := p.dispatch(func(w webview.WebView) error {
p.mu.Lock()
p.url = url
p.mu.Unlock()
w.Navigate(url) w.Navigate(url)
return nil return nil
}); err != nil { }); err != nil {
@@ -193,7 +208,7 @@ func (p *pageWrapper) Goto(url string, opts enginewebview.GotoOptions) error {
if waitUntil == "" { if waitUntil == "" {
waitUntil = "load" waitUntil = "load"
} }
err := p.waitForNavigation(url, timeout, waitUntil) err := p.waitForNavigation(timeout, waitUntil)
if err != nil { if err != nil {
p.tracef("goto failed %dms url=%s err=%v", time.Since(startedAt).Milliseconds(), url, err) p.tracef("goto failed %dms url=%s err=%v", time.Since(startedAt).Milliseconds(), url, err)
return err return err
@@ -233,7 +248,12 @@ func (p *pageWrapper) Execute(expression string, args ...any) (any, error) {
return nil, err 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 { if result.err != nil {
p.tracef("execute failed %dms err=%v", time.Since(startedAt).Milliseconds(), result.err) p.tracef("execute failed %dms err=%v", time.Since(startedAt).Milliseconds(), result.err)
return result.value, result.err return result.value, result.err
@@ -321,6 +341,7 @@ func (p *pageWrapper) Close() error {
return nil return nil
} }
p.closed = true p.closed = true
p.failPendingLocked()
w := p.view w := p.view
wait := p.done wait := p.done
p.mu.Unlock() p.mu.Unlock()
@@ -343,6 +364,7 @@ func (p *pageWrapper) Close() error {
<-terminateDone <-terminateDone
} }
<-wait <-wait
p.events.Emit(enginewebview.Event{Name: "closed", Data: map[string]any{"reason": "api"}})
return nil return nil
} }
@@ -353,94 +375,29 @@ func (p *pageWrapper) tracef(format string, args ...any) {
fmt.Printf("goprovider "+format+"\n", args...) fmt.Printf("goprovider "+format+"\n", args...)
} }
func (p *pageWrapper) waitForNavigation(targetURL string, timeout time.Duration, waitUntil string) error { func (p *pageWrapper) waitForNavigation(timeout time.Duration, waitUntil string) error {
deadline := time.Now().Add(timeout) timer := time.NewTimer(timeout)
ticker := time.NewTicker(100 * time.Millisecond) defer timer.Stop()
defer ticker.Stop()
for { for {
select { 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: case <-p.loads:
if waitUntil == "load" { return nil
return nil
}
case <-ticker.C:
state, err := p.navigationState()
if err == nil && state.ready(targetURL, waitUntil) {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("webview navigation timeout")
}
} }
} }
} }
type navigationState struct {
URL string `json:"url"`
ReadyState string `json:"readyState"`
}
func (s navigationState) ready(targetURL string, waitUntil string) bool {
if s.URL == "" || s.URL == "about:blank" {
return false
}
switch waitUntil {
case "domcontentloaded":
if s.ReadyState == "loading" || s.ReadyState == "" {
return false
}
default:
if s.ReadyState != "complete" {
return false
}
}
if targetURL == "" {
return true
}
if s.URL == targetURL {
return true
}
target, err := parseURL(targetURL)
if err != nil {
return true
}
current, err := parseURL(s.URL)
if err != nil {
return true
}
if current.Scheme == target.Scheme && current.Host == target.Host && current.Path == target.Path {
return true
}
return false
}
func parseURL(raw string) (*url.URL, error) {
return url.Parse(raw)
}
func (p *pageWrapper) navigationState() (navigationState, error) {
value, err := p.Execute(`() => ({
url: String(location.href || ""),
readyState: document.readyState || "",
})`)
if err != nil {
return navigationState{}, err
}
stateMap, ok := value.(map[string]any)
if !ok {
return navigationState{}, fmt.Errorf("invalid navigation state")
}
state := navigationState{}
if urlValue, ok := stateMap["url"].(string); ok {
state.URL = urlValue
}
if readyValue, ok := stateMap["readyState"].(string); ok {
state.ReadyState = readyValue
}
return state, nil
}
func (p *pageWrapper) dispatch(fn func(w webview.WebView) error) error { func (p *pageWrapper) dispatch(fn func(w webview.WebView) error) error {
p.mu.Lock() p.mu.Lock()
if p.closed { if p.closed {
@@ -457,7 +414,12 @@ func (p *pageWrapper) dispatch(fn func(w webview.WebView) error) error {
w.Dispatch(func() { w.Dispatch(func() {
done <- fn(w) 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) { func (p *pageWrapper) nativeCookies() ([]nativeCookieJSON, error) {
@@ -515,7 +477,31 @@ func (p *pageWrapper) handleCallback(payload string) error {
} }
return nil 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 { if msg.Load {
p.events.Emit(enginewebview.Event{Name: "load", Data: map[string]any{"url": msg.URL}})
select { select {
case p.loads <- msg.URL: case p.loads <- msg.URL:
default: default:
@@ -540,6 +526,22 @@ func (p *pageWrapper) handleCallback(payload string) error {
} }
func (p *pageWrapper) drainLoads() { func (p *pageWrapper) drainLoads() {
for {
select {
case <-p.domReady:
continue
default:
}
break
}
for {
select {
case <-p.sameDocument:
continue
default:
}
break
}
for { for {
select { select {
case <-p.loads: case <-p.loads:
@@ -601,19 +603,74 @@ func buildExecuteScript(callbackName string, reqID string, expression string, ar
func buildLoadNotifyScript(callbackName string) string { func buildLoadNotifyScript(callbackName string) string {
callbackJSON, _ := json.Marshal(callbackName) callbackJSON, _ := json.Marshal(callbackName)
return fmt.Sprintf(`(() => { return fmt.Sprintf(`(() => {
window.addEventListener("load", () => { if (window !== window.top) return;
const __cb = globalThis[%s]; const send = (message) => {
if (typeof __cb !== "function") { const cb = globalThis[%s];
return; if (typeof cb === "function") cb(JSON.stringify(message));
} };
__cb(JSON.stringify({ let lastURL = String(location.href);
load: true, const changed = () => {
url: String(location.href || ""), const url = String(location.href);
})); if (url === lastURL) return;
}, { once: true }); 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)) })();`, 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 { func defaultWindowDimension(value int, fallback int) int {
if value > 0 { if value > 0 {
return value return value
@@ -15,3 +15,5 @@ func TestProviderContract(t *testing.T) {
} }
func TestProviderProfiles(t *testing.T) { integrationtest.RunProfileContract(t, New()) } func TestProviderProfiles(t *testing.T) { integrationtest.RunProfileContract(t, New()) }
func TestProviderEvents(t *testing.T) { integrationtest.RunEventContract(t, New()) }
+111
View File
@@ -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()
}
@@ -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")
}
}
+104 -7
View File
@@ -9,6 +9,8 @@ import (
"net" "net"
"net/http" "net/http"
"strings" "strings"
"sync"
"time"
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" 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 { type page struct {
client *Client eventMu sync.Mutex
id string events enginewebview.EventHub
eventCancel context.CancelFunc
eventDone chan struct{}
eventErr error
client *Client
id string
} }
func (p *page) AddInitScript(script string) error { func (p *page) AddInitScript(script string) error {
@@ -110,14 +117,28 @@ func (p *page) ClearCookies() error {
} }
func (p *page) Close() error { func (p *page) Close() error {
err := p.client.Call(enginewebview.MethodPageClose, enginewebview.PageCloseParams{ err := p.client.Call(enginewebview.MethodPageClose, enginewebview.PageCloseParams{PageID: p.id}, nil)
PageID: p.id,
}, nil)
var rpcErr *enginewebview.RPCError var rpcErr *enginewebview.RPCError
if errors.As(err, &rpcErr) && rpcErr.Code == enginewebview.ErrorCodePageNotFound { 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 { 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 { func (p *Provider) RemoveProfile(profileID, dataPath string) error {
return p.client.Call(enginewebview.MethodProfileRemove, enginewebview.ProfileRemoveParams{ProfileID: profileID}, nil) 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
}
@@ -42,3 +42,7 @@ func TestProviderProfiles(t *testing.T) {
provider := New(enginewebview.RPCConfig{Network: *webViewRPCNetwork, Address: *webViewRPCAddress, Token: *webViewRPCToken}) provider := New(enginewebview.RPCConfig{Network: *webViewRPCNetwork, Address: *webViewRPCAddress, Token: *webViewRPCToken})
integrationtest.RunProfileContract(t, provider) integrationtest.RunProfileContract(t, provider)
} }
func TestProviderEvents(t *testing.T) {
integrationtest.RunEventContract(t, New(enginewebview.RPCConfig{Network: *webViewRPCNetwork, Address: *webViewRPCAddress, Token: *webViewRPCToken}))
}
+5
View File
@@ -276,3 +276,8 @@ func callExportedFunction(fn func(goja.FunctionCall) goja.Value, args ...goja.Va
Arguments: args, Arguments: args,
}), nil }), 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)
}
+93
View File
@@ -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)
}
@@ -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")
}
}
+1
View File
@@ -8,6 +8,7 @@ const (
MethodProfileRemove = "profile.remove" MethodProfileRemove = "profile.remove"
MethodIsAvailable = "webview.isAvailable" MethodIsAvailable = "webview.isAvailable"
MethodPageOpen = "page.open" MethodPageOpen = "page.open"
MethodPageEvents = "page.events"
MethodPageAddInitScript = "page.addInitScript" MethodPageAddInitScript = "page.addInitScript"
MethodPageGoto = "page.goto" MethodPageGoto = "page.goto"
MethodPageExecute = "page.execute" MethodPageExecute = "page.execute"
+13 -11
View File
@@ -376,7 +376,7 @@ func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, c
gopeed.Runtime = &InstanceRuntime{ gopeed.Runtime = &InstanceRuntime{
WebView: d.newExtensionWebViewRuntime(session, ext.buildIdentity()), WebView: d.newExtensionWebViewRuntime(session, ext.buildIdentity()),
} }
err = injectGopeed(engine.Runtime, gopeed) err = injectGopeed(engine.Runtime, gopeed, engine.Post)
if err != nil { if err != nil {
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] engine inject failed", ext.buildIdentity()) gopeed.Logger.logger.Error().Err(err).Msgf("[%s] engine inject failed", ext.buildIdentity())
return return
@@ -651,20 +651,22 @@ func (h InstanceEvents) OnDone(fn engine.JSFunction) {
} }
type ExtensionInfo struct { type ExtensionInfo struct {
Identity string `json:"identity"` HostVersion string `json:"hostVersion"`
Name string `json:"name"` Identity string `json:"identity"`
Author string `json:"author"` Name string `json:"name"`
Title string `json:"title"` Author string `json:"author"`
Version string `json:"version"` Title string `json:"title"`
Version string `json:"version"`
} }
func NewExtensionInfo(ext *Extension) *ExtensionInfo { func NewExtensionInfo(ext *Extension) *ExtensionInfo {
return &ExtensionInfo{ return &ExtensionInfo{
Identity: ext.buildIdentity(), HostVersion: base.Version,
Name: ext.Name, Identity: ext.buildIdentity(),
Author: ext.Author, Name: ext.Name,
Title: ext.Title, Author: ext.Author,
Version: ext.Version, Title: ext.Title,
Version: ext.Version,
} }
} }
+1 -1
View File
@@ -33,7 +33,7 @@ func (d *Downloader) NewExtensionEngine(ext *Extension, settings map[string]any)
WebView: d.newExtensionWebViewRuntime(session, ext.buildIdentity()), 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() session.CloseIfIdle()
return nil, err return nil, err
} }
@@ -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)
}
}
+49 -20
View File
@@ -8,7 +8,7 @@ import (
"github.com/dop251/goja" "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() gopeedObject := vm.NewObject()
if gopeed == nil { if gopeed == nil {
return vm.Set("gopeed", gopeedObject) return vm.Set("gopeed", gopeedObject)
@@ -37,7 +37,7 @@ func injectGopeed(vm *goja.Runtime, gopeed *Instance) error {
return err return err
} }
if gopeed.Runtime.WebView != nil { 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 return err
} }
} }
@@ -103,7 +103,7 @@ func newJSEventsRuntime(vm *goja.Runtime, events InstanceEvents) *goja.Object {
return obj 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 := vm.NewObject()
_ = obj.Set("isAvailable", func(goja.FunctionCall) goja.Value { _ = obj.Set("isAvailable", func(goja.FunctionCall) goja.Value {
return vm.ToValue(runtime.IsAvailable()) return vm.ToValue(runtime.IsAvailable())
@@ -113,35 +113,48 @@ func newJSWebViewRuntime(vm *goja.Runtime, runtime *enginewebview.Runtime) *goja
if err != nil { if err != nil {
panic(vm.ToValue(err)) panic(vm.ToValue(err))
} }
return newJSWebViewPage(vm, page) return newJSWebViewPage(vm, page, post)
}) })
return obj 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 := 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 { _ = obj.Set("addInitScript", func(call goja.FunctionCall) goja.Value {
script, err := requireStringArg(call, 0, "script") script, err := requireStringArg(call, 0, "script")
if err != nil { if err != nil {
panic(vm.ToValue(err)) panic(vm.ToValue(err))
} }
if err := page.AddInitScript(script); err != nil { return webviewPromise(vm, post, func() (any, error) { return nil, page.AddInitScript(script) })
panic(vm.ToValue(err))
}
return goja.Undefined()
}) })
_ = obj.Set("goto", func(call goja.FunctionCall) goja.Value { _ = obj.Set("goto", func(call goja.FunctionCall) goja.Value {
url, err := requireStringArg(call, 0, "url") url, err := requireStringArg(call, 0, "url")
if err != nil { if err != nil {
panic(vm.ToValue(err)) panic(vm.ToValue(err))
} }
if err := page.Goto( opts := optionalMap(call.Argument(1))
url, return webviewPromise(vm, post, func() (any, error) { return nil, page.Goto(url, opts) })
optionalMap(call.Argument(1)),
); err != nil {
panic(vm.ToValue(err))
}
return goja.Undefined()
}) })
_ = obj.Set("execute", func(call goja.FunctionCall) goja.Value { _ = obj.Set("execute", func(call goja.FunctionCall) goja.Value {
expression, err := enginewebview.NormalizeExecutableValue(call.Argument(0)) 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) return vm.ToValue(result)
}) })
_ = obj.Set("close", func(goja.FunctionCall) goja.Value { _ = obj.Set("close", func(goja.FunctionCall) goja.Value {
if err := page.Close(); err != nil { return webviewPromise(vm, post, func() (any, error) { return nil, page.Close() })
panic(vm.ToValue(err))
}
return goja.Undefined()
}) })
return obj return obj
} }
@@ -290,3 +300,22 @@ func requireStringArg(call goja.FunctionCall, index int, name string) (string, e
} }
return value.String(), nil 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)
}
+8
View File
@@ -181,3 +181,11 @@ func (d *Downloader) removeExtensionWebViewProfile(identity string) error {
} }
return os.RemoveAll(path) 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)
}
@@ -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});
})();
''';
@@ -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'; import 'webview_rpc_service.dart';
@@ -70,11 +73,24 @@ class _WebViewRpcPageViewState extends State<_WebViewRpcPageView> {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Text( child: Row(
page.title.isNotEmpty ? page.title : 'WebView', children: [
maxLines: 1, Expanded(
overflow: TextOverflow.ellipsis, child: Text(
style: Theme.of(context).textTheme.titleSmall, 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), Expanded(child: content),
+75 -11
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -10,6 +11,7 @@ import '../../core/common/start_config.dart';
import '../../util/log_util.dart'; import '../../util/log_util.dart';
import '../../util/util.dart'; import '../../util/util.dart';
import 'server.dart'; import 'server.dart';
import 'webview_event_script.dart';
String buildWebViewExecuteScript({ String buildWebViewExecuteScript({
required String channelName, required String channelName,
@@ -117,6 +119,38 @@ class WebViewRpcService {
final method = body['method'] as String? ?? ''; final method = body['method'] as String? ?? '';
final params = (body['params'] as Map?)?.cast<String, dynamic>() ?? const <String, dynamic>{}; final params = (body['params'] as Map?)?.cast<String, dynamic>() ?? const <String, dynamic>{};
try { try {
if (method == 'page.events') {
final page = _page(params);
ctx.response.headers.contentType = ContentType('application', 'x-ndjson');
ctx.response.bufferOutput = false;
final output = StreamController<List<int>>();
void send(Map<String, dynamic> 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); final result = await _dispatch(method, params);
await ctx.writeJSON({'result': result, 'error': null}); await ctx.writeJSON({'result': result, 'error': null});
} catch (e, stackTrace) { } catch (e, stackTrace) {
@@ -261,13 +295,15 @@ class WebViewRpcService {
throw WebViewRpcException(code: 'INVALID_REQUEST', message: 'missing or invalid "cookie"'); throw WebViewRpcException(code: 'INVALID_REQUEST', message: 'missing or invalid "cookie"');
} }
Future<void> _closePage(String pageId) async { Future<void> closePageByUser(String pageId) => _closePage(pageId, reason: 'user');
Future<void> _closePage(String pageId, {String reason = 'api'}) async {
final page = _pagesById.remove(pageId); final page = _pagesById.remove(pageId);
if (page == null) { if (page == null) {
throw WebViewRpcException(code: 'PAGE_NOT_FOUND', message: 'page not found'); throw WebViewRpcException(code: 'PAGE_NOT_FOUND', message: 'page not found');
} }
_syncPages(); _syncPages();
await page.dispose(); await page.dispose(reason: reason);
} }
void _syncPages() { void _syncPages() {
@@ -309,6 +345,12 @@ class WebViewRpcPageSession {
required this.userAgent, required this.userAgent,
}); });
final StreamController<Map<String, dynamic>> _events = StreamController.broadcast(sync: true);
Stream<Map<String, dynamic>> get events => _events.stream;
void _emit(String event, Map<String, dynamic> data) {
if (!_disposed) _events.add({'event': event, 'data': data});
}
final String pageId; final String pageId;
final WebViewProfile profile; final WebViewProfile profile;
late final CookieManager cookieManager = profile.cookieManager; late final CookieManager cookieManager = profile.cookieManager;
@@ -328,11 +370,19 @@ class WebViewRpcPageSession {
InAppWebViewController? _controller; InAppWebViewController? _controller;
Completer<void>? _navigation; Completer<void>? _navigation;
String _currentUrl = ''; String _currentUrl = '';
String _eventUrl = '';
int _executeSeq = 0; int _executeSeq = 0;
bool _disposed = false; bool _disposed = false;
Future<void> init() async { Future<void> init() async {
callbackChannelName = '__gopeedWebViewCallback_${pageId.replaceAll('-', '_')}'; callbackChannelName = '__gopeedWebViewCallback_${pageId.replaceAll('-', '_')}';
_initScripts.add(
UserScript(
source: buildWebViewEventScript(callbackChannelName),
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
forMainFrameOnly: true,
),
);
if (headless) { if (headless) {
_headlessWebView = HeadlessInAppWebView( _headlessWebView = HeadlessInAppWebView(
initialSize: Size(width > 0 ? width.toDouble() : 1, height > 0 ? height.toDouble() : 1), initialSize: Size(width > 0 ? width.toDouble() : 1, height > 0 ? height.toDouble() : 1),
@@ -354,14 +404,6 @@ class WebViewRpcPageSession {
_initScripts.add(userScript); _initScripts.add(userScript);
final controller = await _controllerOrThrow(); final controller = await _controllerOrThrow();
await controller.addUserScript(userScript: userScript); await controller.addUserScript(userScript: userScript);
if (_currentUrl.isNotEmpty && !_disposed) {
try {
await controller.evaluateJavascript(source: script);
} catch (_) {
// Best-effort only for the current page. The user script persists
// for future navigations through the native user script registry.
}
}
} }
Future<void> goto(String url, {int? timeoutMs, String? waitUntil}) async { Future<void> goto(String url, {int? timeoutMs, String? waitUntil}) async {
@@ -507,8 +549,11 @@ class WebViewRpcPageSession {
await cookieManager.deleteAllCookies(); await cookieManager.deleteAllCookies();
} }
Future<void> dispose() async { Future<void> dispose({String reason = 'api'}) async {
if (_disposed) return;
_emit('closed', {'reason': reason});
_disposed = true; _disposed = true;
unawaited(_events.close());
if (!_ready.isCompleted) { if (!_ready.isCompleted) {
_ready.completeError(WebViewRpcException(code: 'PAGE_NOT_FOUND', message: 'webview page closed')); _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) { 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)); _completeNavigationError(WebViewRpcException(code: 'NAVIGATION_FAILED', message: error.description));
} }
@@ -595,6 +642,11 @@ class WebViewRpcPageSession {
WebResourceRequest request, WebResourceRequest request,
WebResourceResponse errorResponse, WebResourceResponse errorResponse,
) { ) {
if (request.isForMainFrame != true) return;
_emit('load-error', {
'url': request.url.toString(),
'message': 'HTTP ${errorResponse.statusCode}: ${errorResponse.reasonPhrase ?? 'navigation failed'}',
});
_completeNavigationError( _completeNavigationError(
WebViewRpcException( WebViewRpcException(
code: 'NAVIGATION_FAILED', code: 'NAVIGATION_FAILED',
@@ -651,6 +703,18 @@ class WebViewRpcPageSession {
return; return;
} }
final message = decoded.cast<String, dynamic>(); final message = decoded.cast<String, dynamic>();
final event = message['event'];
if (event == 'url-changed' || event == 'load') {
final data = (message['data'] as Map).cast<String, dynamic>();
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(); final id = message['id']?.toString();
if (id == null || id.isEmpty) { if (id == null || id.isEmpty) {
return; return;
@@ -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<WebViewRpcException>()));
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'},
},
]);
});
}
@@ -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});
})();
''';
@@ -79,12 +79,19 @@ class _WebViewRpcPageViewState extends State<_WebViewRpcPageView> {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Text( child: Row(children: [
page.title.isNotEmpty ? page.title : 'WebView', Expanded(
maxLines: 1, child: Text(
overflow: TextOverflow.ellipsis, page.title.isNotEmpty ? page.title : 'WebView',
style: Theme.of(context).textTheme.titleSmall, 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), Expanded(child: content),
], ],
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.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/log_util.dart';
import '../../util/util.dart'; import '../../util/util.dart';
import 'server.dart'; import 'server.dart';
import 'webview_event_script.dart';
String buildWebViewExecuteScript({ String buildWebViewExecuteScript({
required String channelName, required String channelName,
@@ -124,6 +126,38 @@ class WebViewRpcService {
final params = (body['params'] as Map?)?.cast<String, dynamic>() ?? final params = (body['params'] as Map?)?.cast<String, dynamic>() ??
const <String, dynamic>{}; const <String, dynamic>{};
try { try {
if (method == 'page.events') {
final page = _page(params);
ctx.response.headers.contentType =
ContentType('application', 'x-ndjson');
ctx.response.bufferOutput = false;
final output = StreamController<List<int>>();
void send(Map<String, dynamic> 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); final result = await _dispatch(method, params);
await ctx.writeJSON({ await ctx.writeJSON({
'result': result, 'result': result,
@@ -284,7 +318,10 @@ class WebViewRpcService {
); );
} }
Future<void> _closePage(String pageId) async { Future<void> closePageByUser(String pageId) =>
_closePage(pageId, reason: 'user');
Future<void> _closePage(String pageId, {String reason = 'api'}) async {
final page = _pagesById.remove(pageId); final page = _pagesById.remove(pageId);
if (page == null) { if (page == null) {
throw WebViewRpcException( throw WebViewRpcException(
@@ -293,7 +330,7 @@ class WebViewRpcService {
); );
} }
_syncPages(); _syncPages();
await page.dispose(); await page.dispose(reason: reason);
} }
void _syncPages() { void _syncPages() {
@@ -340,6 +377,13 @@ class WebViewRpcPageSession {
required this.userAgent, required this.userAgent,
}); });
final StreamController<Map<String, dynamic>> _events =
StreamController.broadcast(sync: true);
Stream<Map<String, dynamic>> get events => _events.stream;
void _emit(String event, Map<String, dynamic> data) {
if (!_disposed) _events.add({'event': event, 'data': data});
}
final String pageId; final String pageId;
final CookieManager cookieManager; final CookieManager cookieManager;
final bool headless; final bool headless;
@@ -359,12 +403,18 @@ class WebViewRpcPageSession {
InAppWebViewController? _controller; InAppWebViewController? _controller;
Completer<void>? _navigation; Completer<void>? _navigation;
String _currentUrl = ''; String _currentUrl = '';
String _eventUrl = '';
int _executeSeq = 0; int _executeSeq = 0;
bool _disposed = false; bool _disposed = false;
Future<void> init() async { Future<void> init() async {
callbackChannelName = callbackChannelName =
'__gopeedWebViewCallback_${pageId.replaceAll('-', '_')}'; '__gopeedWebViewCallback_${pageId.replaceAll('-', '_')}';
_initScripts.add(UserScript(
source: buildWebViewEventScript(callbackChannelName),
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
forMainFrameOnly: true,
));
if (headless) { if (headless) {
_headlessWebView = HeadlessInAppWebView( _headlessWebView = HeadlessInAppWebView(
initialSize: Size( initialSize: Size(
@@ -392,14 +442,6 @@ class WebViewRpcPageSession {
_initScripts.add(userScript); _initScripts.add(userScript);
final controller = await _controllerOrThrow(); final controller = await _controllerOrThrow();
await controller.addUserScript(userScript: userScript); await controller.addUserScript(userScript: userScript);
if (_currentUrl.isNotEmpty && !_disposed) {
try {
await controller.evaluateJavascript(source: script);
} catch (_) {
// Best-effort only for the current page. The user script persists
// for future navigations through the native user script registry.
}
}
} }
Future<void> goto(String url, {int? timeoutMs, String? waitUntil}) async { Future<void> goto(String url, {int? timeoutMs, String? waitUntil}) async {
@@ -564,8 +606,11 @@ class WebViewRpcPageSession {
await cookieManager.deleteAllCookies(); await cookieManager.deleteAllCookies();
} }
Future<void> dispose() async { Future<void> dispose({String reason = 'api'}) async {
if (_disposed) return;
_emit('closed', {'reason': reason});
_disposed = true; _disposed = true;
unawaited(_events.close());
if (!_ready.isCompleted) { if (!_ready.isCompleted) {
_ready.completeError( _ready.completeError(
WebViewRpcException( WebViewRpcException(
@@ -668,6 +713,9 @@ class WebViewRpcPageSession {
WebResourceRequest request, WebResourceRequest request,
WebResourceError error, WebResourceError error,
) { ) {
if (request.isForMainFrame != true) return;
_emit('load-error',
{'url': request.url.toString(), 'message': error.description});
_completeNavigationError( _completeNavigationError(
WebViewRpcException( WebViewRpcException(
code: 'NAVIGATION_FAILED', code: 'NAVIGATION_FAILED',
@@ -681,6 +729,12 @@ class WebViewRpcPageSession {
WebResourceRequest request, WebResourceRequest request,
WebResourceResponse errorResponse, WebResourceResponse errorResponse,
) { ) {
if (request.isForMainFrame != true) return;
_emit('load-error', {
'url': request.url.toString(),
'message':
'HTTP ${errorResponse.statusCode}: ${errorResponse.reasonPhrase ?? 'navigation failed'}'
});
_completeNavigationError( _completeNavigationError(
WebViewRpcException( WebViewRpcException(
code: 'NAVIGATION_FAILED', code: 'NAVIGATION_FAILED',
@@ -741,6 +795,20 @@ class WebViewRpcPageSession {
return; return;
} }
final message = decoded.cast<String, dynamic>(); final message = decoded.cast<String, dynamic>();
final event = message['event'];
if (event == 'url-changed' || event == 'load') {
final data = (message['data'] as Map).cast<String, dynamic>();
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(); final id = message['id']?.toString();
if (id == null || id.isEmpty) { if (id == null || id.isEmpty) {
return; return;