mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-14 12:23:43 +00:00
feat(extension): expose host version and WebView page events
This commit is contained in:
1 parent
7c1447f659
commit
dfe8e9a3fd
27 files changed
+1230
-191
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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`
|
||||||
Vendored
+48
@@ -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()) }
|
||||||
@@ -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()) }
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
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
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user