feat: add WebView support for extension (#1359)

This commit is contained in:
Levi authored and GitHub committed 2026-04-24 22:16:33 +08:00
1 parent a205c0b8d7
commit def54f8998
92 files changed
+10248 -2676

No files matched your search

@@ -0,0 +1,36 @@
//go:build darwin
package goprovider
var mainThreadTasks chan func()
func RunMainThreadLoop(run func() int) int {
if mainThreadTasks != nil {
return run()
}
mainThreadTasks = make(chan func())
resultCh := make(chan int, 1)
go func() {
resultCh <- run()
}()
for {
select {
case task := <-mainThreadTasks:
task()
case code := <-resultCh:
mainThreadTasks = nil
return code
}
}
}
func postMainThreadTask(task func()) bool {
if mainThreadTasks == nil {
return false
}
mainThreadTasks <- task
return true
}
@@ -0,0 +1,11 @@
//go:build !darwin
package goprovider
func RunMainThreadLoop(run func() int) int {
return run()
}
func postMainThreadTask(func()) bool {
return false
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,15 @@
//go:build cgo && webview && !darwin
package goprovider
import (
"testing"
"github.com/GopeedLab/gopeed/internal/webview/integrationtest"
)
func TestProviderContract(t *testing.T) {
integrationtest.RunProviderContract(t, New(), integrationtest.ContractOptions{
CookieDomainMode: integrationtest.CookieDomainModeRequired,
})
}
@@ -0,0 +1,9 @@
//go:build !cgo
package goprovider
import enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
func New() enginewebview.Provider {
return enginewebview.NewUnavailableProvider()
}
@@ -0,0 +1,38 @@
//go:build cgo && darwin
package goprovider
/*
#cgo darwin CFLAGS: -x objective-c
#cgo darwin LDFLAGS: -framework Cocoa
#include <Cocoa/Cocoa.h>
static void gopeedApplyHeadlessWindowOptions(void *windowPtr, int headless) {
if (windowPtr == NULL) {
return;
}
NSWindow *window = (__bridge NSWindow *)windowPtr;
if (headless) {
[window orderOut:nil];
}
}
*/
import "C"
import (
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
webview "github.com/GopeedLab/webview_go"
)
func applyWindowOptions(w webview.WebView, opts enginewebview.OpenOptions) {
C.gopeedApplyHeadlessWindowOptions(w.Window(), C.int(boolToInt(opts.Headless)))
}
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}
@@ -0,0 +1,10 @@
//go:build cgo && !darwin
package goprovider
import (
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
webview "github.com/GopeedLab/webview_go"
)
func applyWindowOptions(_ webview.WebView, _ enginewebview.OpenOptions) {}
@@ -0,0 +1,378 @@
package integrationtest
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
)
const TestUserAgent = "GopeedWebViewIntegration/1.0"
type ContractOptions struct {
AvailabilityTimeout time.Duration
CookieDomainMode CookieDomainMode
CookieTestURL string
}
type CookieDomainMode int
const (
CookieDomainModeRequired CookieDomainMode = iota
CookieDomainModeOmit
)
func RunProviderContract(t *testing.T, provider enginewebview.Provider, opts ContractOptions) {
t.Helper()
timeout := opts.AvailabilityTimeout
if timeout <= 0 {
timeout = 10 * time.Second
}
waitForProviderAvailable(t, provider, timeout)
page, server, testURL := openTestPage(t, provider, enginewebview.OpenOptions{
Headless: true,
Title: "Gopeed WebView Integration",
Width: 640,
Height: 480,
UserAgent: TestUserAgent,
})
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
t.Fatalf("goto failed: %v", err)
}
ua, err := page.Execute(`() => navigator.userAgent`)
if err != nil {
t.Fatalf("navigator.userAgent execute failed: %v", err)
}
if ua != TestUserAgent {
t.Fatalf("unexpected user agent: got %v want %q", ua, TestUserAgent)
}
if err := page.Focus("#name"); err != nil {
t.Fatalf("focus failed: %v", err)
}
activeElement, err := page.Execute(`() => document.activeElement && document.activeElement.id`)
if err != nil {
t.Fatalf("activeElement execute failed: %v", err)
}
if activeElement != "name" {
t.Fatalf("expected active element to be #name, got %v", activeElement)
}
if err := page.Type("#name", "Gopeed", map[string]any{"delay": 5}); err != nil {
t.Fatalf("type failed: %v", err)
}
typeState, err := page.Execute(`() => ({
value: document.querySelector('#name').value,
mirror: document.querySelector('#name-value').textContent,
inputCount: window.__inputEvents.length
})`)
if err != nil {
t.Fatalf("type state execute failed: %v", err)
}
stateMap, ok := typeState.(map[string]any)
if !ok {
t.Fatalf("unexpected type state payload: %T", typeState)
}
if stateMap["value"] != "Gopeed" || stateMap["mirror"] != "Gopeed" {
t.Fatalf("unexpected type state: %#v", stateMap)
}
if stateMap["inputCount"] != float64(len("Gopeed")) {
t.Fatalf("unexpected input event count: %#v", stateMap["inputCount"])
}
if err := page.Click("#clicker"); err != nil {
t.Fatalf("click failed: %v", err)
}
clickCount, err := page.Execute(`() => document.querySelector('#click-count').textContent`)
if err != nil {
t.Fatalf("click count execute failed: %v", err)
}
if clickCount != "1" {
t.Fatalf("expected click count to be 1, got %v", clickCount)
}
found, err := page.WaitForSelector("#delayed", map[string]any{
"timeoutMs": 5000,
"pollIntervalMs": 25,
"visible": true,
})
if err != nil {
t.Fatalf("waitForSelector failed: %v", err)
}
if !found {
t.Fatal("expected delayed selector to appear")
}
value, err := page.WaitForFunction(`() => window.__readyValue`, map[string]any{
"timeoutMs": 5000,
"pollIntervalMs": 25,
})
if err != nil {
t.Fatalf("waitForFunction failed: %v", err)
}
readyValue, ok := value.(map[string]any)
if !ok {
t.Fatalf("unexpected waitForFunction value: %T", value)
}
if readyValue["status"] != "ready" {
t.Fatalf("unexpected waitForFunction payload: %#v", readyValue)
}
found, err = page.WaitForSelector("#missing", map[string]any{
"timeoutMs": 200,
"pollIntervalMs": 25,
})
if err != nil {
t.Fatalf("waitForSelector timeout case failed: %v", err)
}
if found {
t.Fatal("expected missing selector wait to time out")
}
cookieTestURL := opts.CookieTestURL
if cookieTestURL == "" {
cookieTestURL = testURL
}
runCookieLifecycleAssertions(t, page, server, cookieTestURL, opts.CookieDomainMode)
}
func waitForProviderAvailable(t *testing.T, provider enginewebview.Provider, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if provider != nil && provider.IsAvailable() {
return
}
time.Sleep(200 * time.Millisecond)
}
t.Fatalf("provider did not become available within %s", timeout)
}
func openTestPage(t *testing.T, provider enginewebview.Provider, opts enginewebview.OpenOptions) (*enginewebview.PageHandle, *httptest.Server, string) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
switch r.URL.Path {
case "/next":
fmt.Fprint(w, `<!DOCTYPE html>
<html>
<head><title>Next Page</title></head>
<body>
<h1 id="next-page">Navigation complete</h1>
</body>
</html>`)
default:
fmt.Fprint(w, `<!DOCTYPE html>
<html>
<head><title>Test Page</title></head>
<body>
<input id="name" type="text" />
<div id="name-value"></div>
<button id="clicker" type="button">Click me</button>
<div id="click-count">0</div>
<button id="nav-btn" type="button">Navigate</button>
<script>
window.__inputEvents = [];
window.__readyValue = null;
const nameInput = document.getElementById('name');
const nameValue = document.getElementById('name-value');
nameInput.addEventListener('input', () => {
nameValue.textContent = nameInput.value;
window.__inputEvents.push(nameInput.value);
});
document.getElementById('clicker').addEventListener('click', () => {
const counter = document.getElementById('click-count');
counter.textContent = String(Number(counter.textContent || '0') + 1);
});
document.getElementById('nav-btn').addEventListener('click', () => {
setTimeout(() => {
window.location.href = '/next';
}, 50);
});
setTimeout(() => {
const marker = document.createElement('div');
marker.id = 'delayed';
marker.textContent = 'Loaded later';
document.body.appendChild(marker);
}, 150);
setTimeout(() => {
window.__readyValue = { status: 'ready', source: 'timer' };
}, 200);
</script>
</body>
</html>`)
}
}))
t.Cleanup(server.Close)
runtime := enginewebview.NewRuntime(provider, true)
page, err := runtime.Open(map[string]any{
"headless": opts.Headless,
"debug": opts.Debug,
"title": opts.Title,
"width": opts.Width,
"height": opts.Height,
"userAgent": opts.UserAgent,
})
if err != nil {
t.Fatalf("failed to open webview: %v", err)
}
t.Cleanup(func() {
_ = runtime.Close()
})
return page, server, server.URL
}
func runCookieLifecycleAssertions(t *testing.T, page *enginewebview.PageHandle, _ *httptest.Server, testURL string, domainMode CookieDomainMode) {
t.Helper()
parsedURL, err := url.Parse(testURL)
if err != nil {
t.Fatalf("parse cookie test url failed: %v", err)
}
cookieDomain := parsedURL.Hostname()
if err := page.ClearCookies(); err != nil {
t.Fatalf("clearCookies before cookie assertions failed: %v", err)
}
visibleCookie := map[string]any{
"name": "visible",
"value": "1",
"path": "/",
}
secretCookie := map[string]any{
"name": "secret",
"value": "2",
"path": "/",
"httpOnly": true,
"expires": time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano),
}
deleteCookie := map[string]any{
"name": "visible",
"path": "/",
}
if domainMode == CookieDomainModeRequired {
visibleCookie["domain"] = cookieDomain
secretCookie["domain"] = cookieDomain
deleteCookie["domain"] = cookieDomain
}
if err := page.SetCookie(visibleCookie); err != nil {
t.Fatalf("set visible cookie failed: %v", err)
}
if err := page.SetCookie(secretCookie); err != nil {
t.Fatalf("set secret cookie failed: %v", err)
}
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
t.Fatalf("goto after setCookie failed: %v", err)
}
documentCookie, err := page.Execute(`() => document.cookie`)
if err != nil {
t.Fatalf("document.cookie execute failed: %v", err)
}
documentCookieText, ok := documentCookie.(string)
if !ok {
t.Fatalf("unexpected document.cookie payload: %T", documentCookie)
}
if !strings.Contains(documentCookieText, "visible=1") {
t.Fatalf("expected visible cookie in document.cookie, got %q", documentCookieText)
}
if strings.Contains(documentCookieText, "secret=2") {
t.Fatalf("expected httpOnly cookie to be hidden from document.cookie, got %q", documentCookieText)
}
cookies, err := page.GetCookies()
if err != nil {
t.Fatalf("getCookies failed: %v", err)
}
if len(cookies) < 2 {
t.Fatalf("expected at least 2 cookies, got %#v", cookies)
}
if !hasCookie(cookies, "visible", "1") {
t.Fatalf("expected visible cookie in native cookie store, got %#v", cookies)
}
if !hasCookie(cookies, "secret", "2") {
t.Fatalf("expected secret cookie in native cookie store, got %#v", cookies)
}
if err := page.DeleteCookie(deleteCookie); err != nil {
t.Fatalf("deleteCookie failed: %v", err)
}
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
t.Fatalf("goto after deleteCookie failed: %v", err)
}
documentCookie, err = page.Execute(`() => document.cookie`)
if err != nil {
t.Fatalf("document.cookie execute after delete failed: %v", err)
}
documentCookieText, ok = documentCookie.(string)
if !ok {
t.Fatalf("unexpected document.cookie payload after delete: %T", documentCookie)
}
if strings.Contains(documentCookieText, "visible=1") {
t.Fatalf("expected visible cookie to be deleted, got %q", documentCookieText)
}
if strings.Contains(documentCookieText, "secret=2") {
t.Fatalf("expected httpOnly cookie to remain hidden after delete, got %q", documentCookieText)
}
cookies, err = page.GetCookies()
if err != nil {
t.Fatalf("getCookies after delete failed: %v", err)
}
if hasCookie(cookies, "visible", "1") {
t.Fatalf("expected visible cookie to be removed from native store, got %#v", cookies)
}
if !hasCookie(cookies, "secret", "2") {
t.Fatalf("expected secret cookie to remain in native store after delete, got %#v", cookies)
}
if err := page.ClearCookies(); err != nil {
t.Fatalf("clearCookies failed: %v", err)
}
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
t.Fatalf("goto after clearCookies failed: %v", err)
}
documentCookie, err = page.Execute(`() => document.cookie`)
if err != nil {
t.Fatalf("document.cookie execute after clear failed: %v", err)
}
documentCookieText, ok = documentCookie.(string)
if !ok {
t.Fatalf("unexpected document.cookie payload after clear: %T", documentCookie)
}
if documentCookieText != "" {
t.Fatalf("expected document.cookie to be empty after clear, got %q", documentCookieText)
}
cookies, err = page.GetCookies()
if err != nil {
t.Fatalf("getCookies after clear failed: %v", err)
}
if len(cookies) != 0 {
t.Fatalf("expected cookie store to be empty after clear, got %#v", cookies)
}
}
func hasCookie(cookies []enginewebview.Cookie, name string, value string) bool {
for _, cookie := range cookies {
if cookie.Name == name && cookie.Value == value {
return true
}
}
return false
}
+488
View File
@@ -0,0 +1,488 @@
# rpcprovider
`rpcprovider` is the RPC-backed implementation of Gopeed's `webview.Provider` interface.
It is intended for environments where WebView capability is owned by another process, most notably mobile hosts. This package does not implement a WebView itself. Instead, it translates the `pkg/download/engine/webview` page API into single-request `POST + JSON` calls sent to a local host-side RPC service.
## When to use it
Use `rpcprovider` for entrypoints such as `bind/mobile`, where the Go process cannot directly own a native WebView.
Do not use it for desktop or `cmd/web` local WebView integration. Those entrypoints should use `internal/webview/goprovider` instead.
## Configuration
The provider is configured through `pkg/download/engine/webview.RPCConfig`:
```go
type RPCConfig struct {
Network string `json:"network"`
Address string `json:"address"`
Token string `json:"token,omitempty"`
}
```
Notes:
- `Network` currently supports `tcp` and `unix`.
- `Address` examples:
- `127.0.0.1:38765`
- `/path/to/webview.sock`
- `Token` is optional. When set, the client sends `Authorization: Bearer <token>`.
Example:
```go
provider := rpcprovider.New(webview.RPCConfig{
Network: "tcp",
Address: "127.0.0.1:38765",
Token: "secret-token",
})
```
## Transport
- Endpoint: `POST /webview`
- Content type: `application/json`
- One request, one response
- No `id` field yet
- The envelope is intentionally close to JSON-RPC so it can evolve later without renaming the methods or reshaping the payloads
Request example:
```json
{
"method": "page.execute",
"params": {
"pageId": "page-1",
"expression": "document.title",
"args": []
}
}
```
Successful response:
```json
{
"result": {
"title": "Example"
},
"error": null
}
```
Failure response:
```json
{
"result": null,
"error": {
"code": "PAGE_NOT_FOUND",
"message": "page not found"
}
}
```
## Supported methods
- `webview.isAvailable`
- `page.open`
- `page.addInitScript`
- `page.goto`
- `page.execute`
- `page.getCookies`
- `page.setCookie`
- `page.deleteCookie`
- `page.clearCookies`
- `page.close`
The canonical wire models for these methods are defined in:
- [pkg/download/engine/webview/rpc.go](../../../pkg/download/engine/webview/rpc.go)
- [pkg/download/engine/webview/runtime.go](../../../pkg/download/engine/webview/runtime.go)
The README is only a guide. The Go source above is the authoritative protocol reference.
## Method reference
Each method below uses the common envelope:
```json
{
"method": "<method-name>",
"params": { ... }
}
```
and returns:
```json
{
"result": ...,
"error": null
}
```
or:
```json
{
"result": null,
"error": {
"code": "<ERROR_CODE>",
"message": "<message>"
}
}
```
### `webview.isAvailable`
Request:
```json
{
"method": "webview.isAvailable",
"params": {}
}
```
Success:
```json
{
"result": {
"available": true
},
"error": null
}
```
### `page.open`
Request:
```json
{
"method": "page.open",
"params": {
"headless": true,
"debug": false,
"title": "Gopeed WebView",
"width": 1280,
"height": 720,
"userAgent": "Mozilla/5.0 ..."
}
}
```
Success:
```json
{
"result": {
"pageId": "page-1"
},
"error": null
}
```
### `page.addInitScript`
Request:
```json
{
"method": "page.addInitScript",
"params": {
"pageId": "page-1",
"script": "window.__READY__ = true;"
}
}
```
Success:
```json
{
"result": {},
"error": null
}
```
### `page.goto`
Request:
```json
{
"method": "page.goto",
"params": {
"pageId": "page-1",
"url": "https://example.com",
"timeoutMs": 15000,
"waitUntil": "domcontentloaded"
}
}
```
Success:
```json
{
"result": {},
"error": null
}
```
`waitUntil` currently supports `load` and `domcontentloaded`. The default is `load`.
### `page.execute`
Request:
```json
{
"method": "page.execute",
"params": {
"pageId": "page-1",
"expression": "document.title",
"args": []
}
}
```
Success:
```json
{
"result": "Example Domain",
"error": null
}
```
### `page.getCookies`
Request:
```json
{
"method": "page.getCookies",
"params": {
"pageId": "page-1"
}
}
```
Success:
```json
{
"result": [
{
"name": "session",
"value": "abc",
"domain": ".example.com",
"path": "/",
"expires": "2026-03-26T12:00:00Z",
"secure": true,
"httpOnly": true
}
],
"error": null
}
```
### `page.setCookie`
Request:
```json
{
"method": "page.setCookie",
"params": {
"pageId": "page-1",
"cookie": {
"name": "session",
"value": "abc",
"domain": ".example.com",
"path": "/",
"expires": "2026-03-26T12:00:00Z",
"secure": true,
"httpOnly": true
}
}
}
```
Success:
```json
{
"result": {},
"error": null
}
```
### `page.deleteCookie`
Request:
```json
{
"method": "page.deleteCookie",
"params": {
"pageId": "page-1",
"cookie": {
"name": "session",
"domain": ".example.com",
"path": "/"
}
}
}
```
Success:
```json
{
"result": {},
"error": null
}
```
### `page.clearCookies`
Request:
```json
{
"method": "page.clearCookies",
"params": {
"pageId": "page-1"
}
}
```
Success:
```json
{
"result": {},
"error": null
}
```
### `page.close`
Request:
```json
{
"method": "page.close",
"params": {
"pageId": "page-1"
}
}
```
Success:
```json
{
"result": {},
"error": null
}
```
## Method semantics
- `page.open`
- Creates a page session
- Returns `pageId`
- `page.addInitScript`
- Registers a page initialization script
- `page.goto`
- Goes to the target URL
- The server should return only after navigation completes or fails
- `page.execute`
- Executes an expression or serialized function body
- The returned value must be JSON-serializable
- `page.getCookies`
- Returns cookies from the underlying native WebView cookie store
- `page.setCookie`
- Inserts or updates a cookie
- `page.deleteCookie`
- Deletes a cookie using `name/domain/path`
- `page.clearCookies`
- Clears the current cookie store
- `page.close`
- Closes the page and releases related resources
## Cookie model
Cookies use `runtime/webview.Cookie`.
Example payload:
```json
{
"name": "session",
"value": "abc",
"domain": ".example.com",
"path": "/",
"expires": "2026-03-26T12:00:00Z",
"secure": true,
"httpOnly": true
}
```
Notes:
- `expires` uses RFC3339 / RFC3339Nano string form.
- If the host platform supports a full native cookie store, it should return fields such as `httpOnly` and `secure`.
- `setCookie -> goto` is a valid pattern for preloading authenticated session state.
## Error codes
The current error code set is defined in `pkg/download/engine/webview/rpc.go`:
- `INVALID_REQUEST`
- `UNKNOWN_METHOD`
- `UNAVAILABLE`
- `BROWSER_NOT_FOUND`
- `PAGE_NOT_FOUND`
- `NAVIGATION_FAILED`
- `EVALUATION_FAILED`
- `TIMEOUT`
- `INTERNAL_ERROR`
`BROWSER_NOT_FOUND` is a historical leftover from an earlier shape of the protocol. The current protocol no longer has a separate browser model, so this can be cleaned up later if the protocol is tightened further.
## Current protocol boundary
This protocol is intentionally limited:
- Single-direction request/response only
- Not REST-style resource routing
- No bidirectional events
- No streaming event subscriptions
- No host-initiated push channel
If the protocol needs to evolve toward full JSON-RPC later, the intended migration path is to keep the existing `method/params/result/error` structure and add:
- `id`
- notifications
- a bidirectional transport model
## Host implementation guidance
A host implementation should usually:
- Listen on a local `tcp` address or `unix socket`
- Expose `POST /webview`
- Validate the bearer token if configured
- Maintain `pageId -> native webview/session`
- Own page lifecycle, cleanup, and timeout handling
With that split, Gopeed stays a pure RPC client and does not need to know how the host's native WebView is implemented.
+195
View File
@@ -0,0 +1,195 @@
package rpcprovider
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
)
type Provider struct {
client *Client
}
func New(cfg enginewebview.RPCConfig) enginewebview.Provider {
return &Provider{
client: NewClient(cfg),
}
}
func (p *Provider) IsAvailable() bool {
if p == nil || p.client == nil || !p.client.Enabled() {
return false
}
var result enginewebview.IsAvailableResult
if err := p.client.Call(enginewebview.MethodIsAvailable, enginewebview.IsAvailableParams{}, &result); err != nil {
return false
}
return result.Available
}
func (p *Provider) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) {
if p == nil || p.client == nil || !p.IsAvailable() {
return nil, enginewebview.ErrUnavailable
}
var result enginewebview.PageOpenResult
if err := p.client.Call(enginewebview.MethodPageOpen, enginewebview.NewPageOpenParams(opts), &result); err != nil {
return nil, err
}
return &page{
client: p.client,
id: result.PageID,
}, nil
}
type page struct {
client *Client
id string
}
func (p *page) AddInitScript(script string) error {
return p.client.Call(enginewebview.MethodPageAddInitScript, enginewebview.PageAddInitScriptParams{
PageID: p.id,
Script: script,
}, nil)
}
func (p *page) Goto(url string, opts enginewebview.GotoOptions) error {
return p.client.Call(enginewebview.MethodPageGoto, enginewebview.NewPageGotoParams(p.id, url, opts), nil)
}
func (p *page) Execute(expression string, args ...any) (any, error) {
var result any
if err := p.client.Call(enginewebview.MethodPageExecute, enginewebview.PageExecuteParams{
PageID: p.id,
Expression: expression,
Args: args,
}, &result); err != nil {
return nil, err
}
return result, nil
}
func (p *page) GetCookies() ([]enginewebview.Cookie, error) {
var result []enginewebview.Cookie
if err := p.client.Call(enginewebview.MethodPageGetCookies, enginewebview.PageGetCookiesParams{
PageID: p.id,
}, &result); err != nil {
return nil, err
}
if result == nil {
return []enginewebview.Cookie{}, nil
}
return result, nil
}
func (p *page) SetCookie(cookie enginewebview.Cookie) error {
return p.client.Call(enginewebview.MethodPageSetCookie, enginewebview.PageSetCookieParams{
PageID: p.id,
Cookie: cookie,
}, nil)
}
func (p *page) DeleteCookie(cookie enginewebview.Cookie) error {
return p.client.Call(enginewebview.MethodPageDeleteCookie, enginewebview.PageDeleteCookieParams{
PageID: p.id,
Cookie: cookie,
}, nil)
}
func (p *page) ClearCookies() error {
return p.client.Call(enginewebview.MethodPageClearCookies, enginewebview.PageClearCookiesParams{
PageID: p.id,
}, nil)
}
func (p *page) Close() error {
return p.client.Call(enginewebview.MethodPageClose, enginewebview.PageCloseParams{
PageID: p.id,
}, nil)
}
type Client struct {
endpoint string
token string
http *http.Client
}
func NewClient(cfg enginewebview.RPCConfig) *Client {
client := &Client{token: cfg.Token}
if !cfg.Enabled() {
return client
}
switch strings.ToLower(cfg.Network) {
case "unix":
client.endpoint = "http://unix" + enginewebview.RPCEndpointPath
client.http = &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", cfg.Address)
},
},
}
default:
client.endpoint = "http://" + cfg.Address + enginewebview.RPCEndpointPath
client.http = &http.Client{}
}
return client
}
func (c *Client) Enabled() bool {
return c != nil && c.endpoint != "" && c.http != nil
}
func (c *Client) Call(method string, params any, result any) error {
if !c.Enabled() {
return enginewebview.ErrUnavailable
}
payload, err := json.Marshal(enginewebview.RPCRequest{
Method: method,
Params: params,
})
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, c.endpoint, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("webview rpc status %d", resp.StatusCode)
}
var body enginewebview.RPCResponse
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return err
}
if body.Error != nil {
return body.Error
}
if result == nil {
return nil
}
if len(body.Result) == 0 || string(body.Result) == "null" {
return nil
}
return json.Unmarshal(body.Result, result)
}
@@ -0,0 +1,41 @@
//go:build webview && darwin
package rpcprovider
import (
"flag"
"os"
"path/filepath"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/webview/integrationtest"
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
)
var (
webViewRPCNetwork = flag.String("webview-rpc-network", "unix", "WebView RPC network for integration tests")
webViewRPCAddress = flag.String("webview-rpc-address", defaultWebViewRPCAddress(), "WebView RPC address for integration tests")
webViewRPCToken = flag.String("webview-rpc-token", "", "WebView RPC token for integration tests")
)
func TestProviderContract(t *testing.T) {
provider := New(enginewebview.RPCConfig{
Network: *webViewRPCNetwork,
Address: *webViewRPCAddress,
Token: *webViewRPCToken,
})
integrationtest.RunProviderContract(t, provider, integrationtest.ContractOptions{
AvailabilityTimeout: 5 * time.Minute,
CookieDomainMode: integrationtest.CookieDomainModeRequired,
CookieTestURL: "https://example.com/",
})
}
func defaultWebViewRPCAddress() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(homeDir, "Library", "Application Support", "com.gopeed.gopeed", "gopeed_webview.sock")
}
@@ -0,0 +1,50 @@
package rpcprovider
import (
"encoding/json"
"net"
"net/http"
"os"
"path/filepath"
"testing"
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
)
func TestProviderIsAvailableOverUnixSocket(t *testing.T) {
socketPath := filepath.Join("/tmp", "gopeed-rpcwebview-"+t.Name()+".sock")
listener, err := net.Listen("unix", socketPath)
if err != nil {
t.Fatal(err)
}
defer listener.Close()
defer os.Remove(socketPath)
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req enginewebview.RPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatal(err)
}
if req.Method != enginewebview.MethodIsAvailable {
t.Fatalf("unexpected unix method: %s", req.Method)
}
if err := json.NewEncoder(w).Encode(map[string]any{
"result": enginewebview.IsAvailableResult{Available: true},
"error": nil,
}); err != nil {
t.Fatal(err)
}
}),
}
defer server.Close()
go server.Serve(listener)
provider := New(enginewebview.RPCConfig{
Network: "unix",
Address: socketPath,
})
if !provider.IsAvailable() {
t.Fatal("expected unix provider to be available")
}
}
@@ -0,0 +1,186 @@
package rpcprovider
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
)
func TestProviderLifecycleOverHTTP(t *testing.T) {
var calls []enginewebview.RPCRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("unexpected method: %s", r.Method)
}
if r.URL.Path != enginewebview.RPCEndpointPath {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer secret-token" {
t.Fatalf("unexpected auth header: %q", got)
}
var req enginewebview.RPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatal(err)
}
calls = append(calls, req)
var body any
switch req.Method {
case enginewebview.MethodIsAvailable:
body = map[string]any{
"result": enginewebview.IsAvailableResult{Available: true},
"error": nil,
}
case enginewebview.MethodPageOpen:
body = map[string]any{
"result": enginewebview.PageOpenResult{PageID: "page-open-1"},
"error": nil,
}
case enginewebview.MethodPageAddInitScript, enginewebview.MethodPageGoto:
body = map[string]any{
"result": enginewebview.EmptyResult{},
"error": nil,
}
case enginewebview.MethodPageExecute:
body = map[string]any{
"result": map[string]any{"title": "Example"},
"error": nil,
}
case enginewebview.MethodPageClose:
body = map[string]any{
"result": enginewebview.EmptyResult{},
"error": nil,
}
default:
t.Fatalf("unexpected method: %s", req.Method)
}
if err := json.NewEncoder(w).Encode(body); err != nil {
t.Fatal(err)
}
}))
defer server.Close()
cfg := enginewebview.RPCConfig{
Network: "tcp",
Address: server.Listener.Addr().String(),
Token: "secret-token",
}
provider := New(cfg)
if !provider.IsAvailable() {
t.Fatal("expected provider to be available")
}
opener, ok := provider.(enginewebview.Opener)
if !ok {
t.Fatal("expected provider to implement runtime webview opener")
}
opened, err := opener.Open(enginewebview.OpenOptions{
Headless: true,
Width: 1280,
Height: 720,
Title: "Gopeed",
UserAgent: "UA",
})
if err != nil {
t.Fatal(err)
}
if err := opened.AddInitScript("window.__TEST__ = true"); err != nil {
t.Fatal(err)
}
if err := opened.Goto("https://example.com", enginewebview.GotoOptions{
TimeoutMS: 3210,
WaitUntil: "domcontentloaded",
}); err != nil {
t.Fatal(err)
}
value, err := opened.Execute("document.title")
if err != nil {
t.Fatal(err)
}
got, ok := value.(map[string]any)
if !ok || got["title"] != "Example" {
t.Fatalf("unexpected evaluate result: %#v", value)
}
if err := opened.Close(); err != nil {
t.Fatal(err)
}
expectedMethods := []string{
enginewebview.MethodIsAvailable,
enginewebview.MethodIsAvailable,
enginewebview.MethodPageOpen,
enginewebview.MethodPageAddInitScript,
enginewebview.MethodPageGoto,
enginewebview.MethodPageExecute,
enginewebview.MethodPageClose,
}
if len(calls) != len(expectedMethods) {
t.Fatalf("unexpected call count: got %d want %d", len(calls), len(expectedMethods))
}
for i, method := range expectedMethods {
if calls[i].Method != method {
t.Fatalf("unexpected method at %d: got %s want %s", i, calls[i].Method, method)
}
}
gotoParamsJSON, err := json.Marshal(calls[4].Params)
if err != nil {
t.Fatal(err)
}
var gotoParams enginewebview.PageGotoParams
if err := json.Unmarshal(gotoParamsJSON, &gotoParams); err != nil {
t.Fatal(err)
}
if gotoParams.WaitUntil != "domcontentloaded" {
t.Fatalf("unexpected waitUntil: %q", gotoParams.WaitUntil)
}
}
func TestProviderLaunchUnavailableWithoutEndpoint(t *testing.T) {
provider := New(enginewebview.RPCConfig{})
if provider.IsAvailable() {
t.Fatal("expected empty provider to be unavailable")
}
opener, ok := provider.(enginewebview.Opener)
if !ok {
t.Fatal("expected provider to implement runtime webview opener")
}
if _, err := opener.Open(enginewebview.OpenOptions{}); err == nil || err.Error() != enginewebview.ErrUnavailable.Error() {
t.Fatalf("unexpected launch error: %v", err)
}
}
func TestPageSetCookieParamsOmitsZeroExpires(t *testing.T) {
params := enginewebview.PageSetCookieParams{
PageID: "page-1",
Cookie: enginewebview.Cookie{
Name: "visible",
Value: "1",
Domain: "example.com",
Path: "/",
},
}
payload, err := json.Marshal(params)
if err != nil {
t.Fatal(err)
}
if string(payload) == "" {
t.Fatal("expected non-empty json payload")
}
if got := string(payload); strings.Contains(got, `"expires"`) {
t.Fatalf("unexpected zero expires in payload: %s", got)
}
params.Cookie.Expires = time.Now().UTC().Truncate(time.Second)
payload, err = json.Marshal(params)
if err != nil {
t.Fatal(err)
}
if got := string(payload); !strings.Contains(got, `"expires"`) {
t.Fatalf("expected expires in payload: %s", got)
}
}