mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-07 00:50:27 +00:00
Merge latest PR #1527 into 2.0.0-beta.2
This commit is contained in:
commit
dae13dec01
30 files changed
+1444
-194
No files matched your search
@@ -289,3 +289,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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -378,7 +378,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
|
||||
@@ -653,20 +653,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user