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

+5
View File
@@ -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)
}
+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"
MethodIsAvailable = "webview.isAvailable"
MethodPageOpen = "page.open"
MethodPageEvents = "page.events"
MethodPageAddInitScript = "page.addInitScript"
MethodPageGoto = "page.goto"
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{
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,
}
}
+1 -1
View File
@@ -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
}
@@ -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"
)
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)
}
+8
View File
@@ -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)
}