Files
gopeed/pkg/download/engine/webview/runtime.go

766 lines
17 KiB
Go

package webview
import (
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
var ErrUnavailable = errors.New("gopeed runtime webview is unavailable")
type Opener interface {
Open(opts OpenOptions) (Page, error)
}
type Page interface {
AddInitScript(script string) error
Goto(url string, opts GotoOptions) error
Execute(expression string, args ...any) (any, error)
GetCookies() ([]Cookie, error)
SetCookie(cookie Cookie) error
DeleteCookie(cookie Cookie) error
ClearCookies() error
Close() error
}
type OpenOptions struct {
Headless bool
Debug bool
Title string
Width int
Height int
UserAgent string
}
type GotoOptions struct {
TimeoutMS int64
WaitUntil string
}
type ClickOptions struct {
DelayMS int64
}
type TypeOptions struct {
DelayMS int64
}
type WaitOptions struct {
TimeoutMS int64
PollIntervalMS int64
}
type WaitForSelectorOptions struct {
TimeoutMS int64
PollIntervalMS int64
Visible bool
Hidden bool
}
type Cookie struct {
Name string `json:"name"`
Value string `json:"value"`
Domain string `json:"domain,omitempty"`
Path string `json:"path,omitempty"`
Expires time.Time `json:"expires,omitempty,omitzero"`
Secure bool `json:"secure,omitempty"`
HTTPOnly bool `json:"httpOnly,omitempty"`
}
type Runtime struct {
opener Opener
available bool
mu sync.Mutex
pageSeq uint64
pages map[string]Page
}
func NewRuntime(opener Opener, available bool) *Runtime {
if opener == nil {
available = false
}
return &Runtime{
opener: opener,
available: available,
pages: make(map[string]Page),
}
}
func (r *Runtime) IsAvailable() bool {
return r != nil && r.available && r.opener != nil
}
func (r *Runtime) Open(opts ...map[string]any) (*PageHandle, error) {
if !r.IsAvailable() {
return nil, ErrUnavailable
}
page, err := r.opener.Open(parseOpenOptions(firstMap(opts)))
if err != nil {
return nil, err
}
r.mu.Lock()
defer r.mu.Unlock()
r.pageSeq++
id := fmt.Sprintf("page-%d", r.pageSeq)
r.pages[id] = page
return &PageHandle{runtime: r, id: id}, nil
}
func (r *Runtime) Close() error {
r.mu.Lock()
pageIDs := make([]string, 0, len(r.pages))
for id := range r.pages {
pageIDs = append(pageIDs, id)
}
r.mu.Unlock()
var errs []error
for _, id := range pageIDs {
if err := r.closePage(id); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
type PageHandle struct {
runtime *Runtime
id string
}
func (p *PageHandle) AddInitScript(script string) error {
page, err := p.page()
if err != nil {
return err
}
return page.AddInitScript(script)
}
func (p *PageHandle) Goto(url string, opts ...map[string]any) error {
page, err := p.page()
if err != nil {
return err
}
gotoOpts, err := parseGotoOptions(firstMap(opts))
if err != nil {
return err
}
return page.Goto(url, gotoOpts)
}
func (p *PageHandle) Execute(scriptOrFn any, args ...any) (any, error) {
page, err := p.page()
if err != nil {
return nil, err
}
expression, err := normalizeExecutable(scriptOrFn)
if err != nil {
return nil, err
}
return page.Execute(expression, args...)
}
func (p *PageHandle) Focus(selector string) error {
_, err := p.Execute(`(selector) => {
const element = document.querySelector(selector);
if (!element) {
throw new Error("element not found: " + selector);
}
if (typeof element.focus !== "function") {
throw new Error("element is not focusable: " + selector);
}
element.focus();
return true;
}`, selector)
return err
}
func (p *PageHandle) Click(selector string, opts ...map[string]any) error {
clickOpts := parseClickOptions(firstMap(opts))
_, err := p.Execute(`(selector, delayMS) => {
const element = document.querySelector(selector);
if (!element) {
throw new Error("element not found: " + selector);
}
if (typeof element.scrollIntoView === "function") {
element.scrollIntoView({ block: "center", inline: "center" });
}
const performClick = () => {
if (typeof element.click === "function") {
element.click();
return;
}
element.dispatchEvent(new MouseEvent("click", {
bubbles: true,
cancelable: true,
view: window,
}));
};
if (delayMS > 0) {
return new Promise((resolve) => {
setTimeout(() => {
performClick();
resolve(true);
}, delayMS);
});
}
performClick();
return true;
}`, selector, clickOpts.DelayMS)
return err
}
func (p *PageHandle) Type(selector string, text string, opts ...map[string]any) error {
typeOpts := parseTypeOptions(firstMap(opts))
_, err := p.Execute(`(selector, text, delayMS) => {
const element = document.querySelector(selector);
if (!element) {
throw new Error("element not found: " + selector);
}
if (typeof element.focus === "function") {
element.focus();
}
const emitInput = () => {
element.dispatchEvent(new Event("input", { bubbles: true }));
};
const insertIntoInput = (chunk) => {
if (element.disabled) {
throw new Error("element is disabled: " + selector);
}
if (element.readOnly) {
throw new Error("element is readonly: " + selector);
}
const value = typeof element.value === "string" ? element.value : "";
const hasSelection = typeof element.selectionStart === "number" && typeof element.selectionEnd === "number";
const start = hasSelection ? element.selectionStart : value.length;
const end = hasSelection ? element.selectionEnd : value.length;
element.value = value.slice(0, start) + chunk + value.slice(end);
const caret = start + chunk.length;
if (typeof element.setSelectionRange === "function") {
element.setSelectionRange(caret, caret);
}
emitInput();
};
const insertIntoEditable = (chunk) => {
if (element.isContentEditable) {
element.textContent = (element.textContent || "") + chunk;
emitInput();
return;
}
insertIntoInput(chunk);
};
const insertChunk = (chunk) => {
const tagName = (element.tagName || "").toUpperCase();
if (tagName === "INPUT" || tagName === "TEXTAREA" || typeof element.value === "string") {
insertIntoInput(chunk);
return;
}
if (element.isContentEditable) {
insertIntoEditable(chunk);
return;
}
throw new Error("element is not typable: " + selector);
};
const chars = Array.from(String(text));
if (delayMS > 0) {
return new Promise((resolve) => {
let index = 0;
const step = () => {
if (index >= chars.length) {
resolve(true);
return;
}
insertChunk(chars[index]);
index += 1;
setTimeout(step, delayMS);
};
step();
});
}
for (const char of chars) {
insertChunk(char);
}
return true;
}`, selector, text, typeOpts.DelayMS)
return err
}
func (p *PageHandle) WaitForSelector(selector string, opts ...map[string]any) (bool, error) {
waitOpts := parseWaitForSelectorOptions(firstMap(opts))
_, matched, err := p.poll(WaitOptions{
TimeoutMS: waitOpts.TimeoutMS,
PollIntervalMS: waitOpts.PollIntervalMS,
}, func() (any, bool, error) {
value, err := p.Execute(`(selector, visible, hidden) => {
const element = document.querySelector(selector);
if (hidden) {
if (!element) return true;
const style = window.getComputedStyle(element);
if (!style) return !!element.hidden;
const rect = element.getBoundingClientRect();
return element.hidden || style.display === "none" || style.visibility === "hidden" || rect.width === 0 || rect.height === 0;
}
if (!element) return false;
if (!visible) return true;
const style = window.getComputedStyle(element);
if (!style) return true;
const rect = element.getBoundingClientRect();
return !element.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
}`, selector, waitOpts.Visible, waitOpts.Hidden)
if err != nil {
return nil, false, err
}
return true, truthy(value), nil
})
if err != nil {
return false, err
}
return matched, nil
}
func (p *PageHandle) WaitForFunction(scriptOrFn any, args ...any) (any, error) {
expression, err := normalizeExecutable(scriptOrFn)
if err != nil {
return nil, err
}
waitOpts := WaitOptions{}
callArgs := args
if len(args) > 0 {
if raw, ok := args[0].(map[string]any); ok {
waitOpts = parseWaitOptions(raw)
callArgs = args[1:]
}
}
value, matched, err := p.poll(waitOpts, func() (any, bool, error) {
result, err := p.Execute(`(expression, args) => {
const target = (0, eval)(expression);
return Promise.resolve(typeof target === "function" ? target(...args) : target).then((value) => ({
matched: !!value,
value: value ?? null,
}));
}`, expression, callArgs)
if err != nil {
return nil, false, err
}
payload, ok := result.(map[string]any)
if !ok {
return nil, false, fmt.Errorf("unexpected waitForFunction payload: %T", result)
}
return payload["value"], truthy(payload["matched"]), nil
})
if err != nil {
return nil, err
}
if !matched {
return nil, nil
}
return value, nil
}
func (p *PageHandle) GetCookies() ([]Cookie, error) {
page, err := p.page()
if err != nil {
return nil, err
}
return page.GetCookies()
}
func (p *PageHandle) SetCookie(raw map[string]any) error {
page, err := p.page()
if err != nil {
return err
}
return page.SetCookie(parseCookie(raw))
}
func (p *PageHandle) DeleteCookie(raw map[string]any) error {
page, err := p.page()
if err != nil {
return err
}
return page.DeleteCookie(parseCookie(raw))
}
func (p *PageHandle) ClearCookies() error {
page, err := p.page()
if err != nil {
return err
}
return page.ClearCookies()
}
func (p *PageHandle) URL() (string, error) {
value, err := p.Execute(`() => location.href`)
if err != nil {
return "", err
}
return parseString(value), nil
}
func (p *PageHandle) Content() (string, error) {
value, err := p.Execute(`() => document.documentElement ? document.documentElement.outerHTML : ""`)
if err != nil {
return "", err
}
return parseString(value), nil
}
func (p *PageHandle) Close() error {
if p == nil || p.runtime == nil {
return nil
}
return p.runtime.closePage(p.id)
}
func (p *PageHandle) page() (Page, error) {
if p == nil || p.runtime == nil {
return nil, fmt.Errorf("webview page handle is not initialized")
}
return p.runtime.getPage(p.id)
}
func (p *PageHandle) poll(opts WaitOptions, fn func() (any, bool, error)) (any, bool, error) {
timeout := time.Duration(defaultWaitTimeoutMS(opts.TimeoutMS)) * time.Millisecond
pollInterval := time.Duration(defaultPollIntervalMS(opts.PollIntervalMS)) * time.Millisecond
deadline := time.Now().Add(timeout)
for {
value, matched, err := fn()
if err != nil {
return nil, false, err
}
if matched {
return value, true, nil
}
if time.Now().After(deadline) {
return nil, false, nil
}
time.Sleep(pollInterval)
}
}
func (r *Runtime) getPage(id string) (Page, error) {
r.mu.Lock()
defer r.mu.Unlock()
page, ok := r.pages[id]
if !ok {
return nil, fmt.Errorf("page not found")
}
return page, nil
}
func (r *Runtime) closePage(id string) error {
r.mu.Lock()
page, ok := r.pages[id]
if !ok {
r.mu.Unlock()
return nil
}
delete(r.pages, id)
r.mu.Unlock()
return page.Close()
}
func parseOpenOptions(raw map[string]any) OpenOptions {
return OpenOptions{
Headless: parseBool(raw["headless"]),
Debug: parseBool(raw["debug"]),
Title: parseString(raw["title"]),
Width: parseInt(raw["width"]),
Height: parseInt(raw["height"]),
UserAgent: parseString(raw["userAgent"]),
}
}
func parseGotoOptions(raw map[string]any) (GotoOptions, error) {
waitUntil, err := normalizeWaitUntil(parseString(raw["waitUntil"]))
if err != nil {
return GotoOptions{}, err
}
return GotoOptions{
TimeoutMS: parseInt64(raw["timeoutMs"]),
WaitUntil: waitUntil,
}, nil
}
func normalizeWaitUntil(raw string) (string, error) {
if raw == "" {
return "load", nil
}
switch strings.ToLower(strings.TrimSpace(raw)) {
case "load":
return "load", nil
case "domcontentloaded":
return "domcontentloaded", nil
default:
return "", fmt.Errorf("invalid waitUntil: %s", raw)
}
}
func parseClickOptions(raw map[string]any) ClickOptions {
return ClickOptions{DelayMS: parseInt64(raw["delay"])}
}
func parseTypeOptions(raw map[string]any) TypeOptions {
return TypeOptions{DelayMS: parseInt64(raw["delay"])}
}
func parseWaitOptions(raw map[string]any) WaitOptions {
return WaitOptions{
TimeoutMS: parseInt64(raw["timeoutMs"]),
PollIntervalMS: parseInt64(raw["pollIntervalMs"]),
}
}
func parseWaitForSelectorOptions(raw map[string]any) WaitForSelectorOptions {
return WaitForSelectorOptions{
TimeoutMS: parseInt64(raw["timeoutMs"]),
PollIntervalMS: parseInt64(raw["pollIntervalMs"]),
Visible: parseBool(raw["visible"]),
Hidden: parseBool(raw["hidden"]),
}
}
func parseCookie(raw map[string]any) Cookie {
cookie := Cookie{
Name: parseString(raw["name"]),
Value: parseString(raw["value"]),
Domain: parseString(raw["domain"]),
Path: parseString(raw["path"]),
Secure: parseBool(raw["secure"]),
HTTPOnly: parseBool(raw["httpOnly"]),
}
if rawExpires, ok := raw["expires"]; ok {
cookie.Expires = parseTime(rawExpires)
}
return cookie
}
func defaultWaitTimeoutMS(value int64) int64 {
if value <= 0 {
return 10000
}
return value
}
func defaultPollIntervalMS(value int64) int64 {
if value <= 0 {
return 100
}
return value
}
func normalizeExecutable(scriptOrFn any) (string, error) {
switch value := scriptOrFn.(type) {
case string:
return value, nil
case *goja.Object:
return NormalizeExecutableValue(value)
default:
return "", fmt.Errorf("execute expects a string or function, got %T", scriptOrFn)
}
}
func NormalizeExecutableValue(value goja.Value) (string, error) {
if value == nil {
return "", fmt.Errorf("execute expects a string or function")
}
switch raw := value.Export().(type) {
case string:
return raw, nil
}
obj, ok := value.(*goja.Object)
if ok {
if _, ok := goja.AssertFunction(obj); ok {
source, err := functionSource(obj)
if err != nil {
return "", err
}
return normalizeFunctionSource(source), nil
}
}
source := strings.TrimSpace(value.String())
if looksLikeFunctionSource(source) {
return normalizeFunctionSource(source), nil
}
return "", fmt.Errorf("execute expects a string or function, got %T (%q)", value, source)
}
func normalizeFunctionSource(source string) string {
repaired := repairArrowObjectLiteralSource(source)
return "(" + repaired + ")"
}
func functionSource(fn *goja.Object) (string, error) {
toStringValue := fn.Get("toString")
toString, ok := goja.AssertFunction(toStringValue)
if !ok {
return "", fmt.Errorf("function.toString is not callable")
}
value, err := toString(fn)
if err != nil {
return "", err
}
return value.String(), nil
}
func repairArrowObjectLiteralSource(source string) string {
trimmed := strings.TrimSpace(source)
if !strings.Contains(trimmed, "=>") || !strings.Contains(trimmed, "({") {
return source
}
if !strings.HasSuffix(trimmed, "}") {
return source
}
if strings.Count(trimmed, "(")-strings.Count(trimmed, ")") != 1 {
return source
}
if strings.Count(trimmed, "{") != strings.Count(trimmed, "}") {
return source
}
// TODO: Remove this narrow repair once goja fixes toString() for concise arrow
// functions that directly return object literals, then upstream the fix as a PR.
return source + ")"
}
func looksLikeFunctionSource(source string) bool {
if source == "" || source == "[object Object]" {
return false
}
return strings.Contains(source, "=>") ||
strings.HasPrefix(source, "function") ||
strings.HasPrefix(source, "async function") ||
strings.HasPrefix(source, "async (") ||
strings.HasPrefix(source, "async(")
}
func truthy(value any) bool {
switch v := value.(type) {
case nil:
return false
case bool:
return v
case string:
return v != ""
case int:
return v != 0
case int8:
return v != 0
case int16:
return v != 0
case int32:
return v != 0
case int64:
return v != 0
case uint:
return v != 0
case uint8:
return v != 0
case uint16:
return v != 0
case uint32:
return v != 0
case uint64:
return v != 0
case float32:
return v != 0
case float64:
return v != 0
default:
return true
}
}
func firstMap(values []map[string]any) map[string]any {
if len(values) == 0 || values[0] == nil {
return map[string]any{}
}
return values[0]
}
func parseString(value any) string {
if value == nil {
return ""
}
return fmt.Sprint(value)
}
func parseBool(value any) bool {
switch v := value.(type) {
case bool:
return v
case string:
parsed, _ := strconv.ParseBool(v)
return parsed
default:
return false
}
}
func parseInt(value any) int {
return int(parseInt64(value))
}
func parseInt64(value any) int64 {
switch v := value.(type) {
case int:
return int64(v)
case int8:
return int64(v)
case int16:
return int64(v)
case int32:
return int64(v)
case int64:
return v
case uint:
return int64(v)
case uint8:
return int64(v)
case uint16:
return int64(v)
case uint32:
return int64(v)
case uint64:
return int64(v)
case float32:
return int64(v)
case float64:
return int64(v)
case string:
parsed, _ := strconv.ParseInt(v, 10, 64)
return parsed
default:
return 0
}
}
func parseTime(value any) time.Time {
switch v := value.(type) {
case time.Time:
return v
case int64:
return time.UnixMilli(v)
case int:
return time.UnixMilli(int64(v))
case float64:
return time.UnixMilli(int64(v))
case string:
if parsed, err := time.Parse(time.RFC3339Nano, v); err == nil {
return parsed
}
if ms, err := strconv.ParseInt(v, 10, 64); err == nil {
return time.UnixMilli(ms)
}
}
return time.Time{}
}