Files
Ronni Skansing 36ee621f1a Remote Browser Feature
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2026-05-14 10:54:56 +02:00

686 lines
21 KiB
Go

package remotebrowser
import (
"context"
"fmt"
"time"
"github.com/dop251/goja"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/input"
"github.com/go-rod/rod/lib/proto"
)
// RegisterBrowserBindings wires up all rod actions as callable JS functions
// on the provided target object. All actions block until completion.
// emitter may be nil (e.g. in withTimeout sub-sessions); screenshots and debug logs
// are silently dropped when nil. debug adds before/after log lines for every action.
//
// Debug symbol legend:
//
// → action starting ✓ action done … waiting ? reading = result value
func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, emitter *channelEmitter, debug bool, queryTimeout int) {
must := func(err error) {
if err != nil {
panic(vm.NewGoError(err))
}
}
// argStr returns the string value of a goja argument, or "" for undefined/null.
argStr := func(v goja.Value) string {
if goja.IsUndefined(v) || goja.IsNull(v) {
return ""
}
return v.String()
}
dbg := func(msg string) {
if debug && emitter != nil {
emitter.log("[dbg] " + msg)
}
}
// readPage returns a page for read-only CDP queries (getNodeCount, getText,
// evaluate, screenshot, …). When queryTimeout > 0 it is bounded so a stalled
// browser can't freeze the goja listen loop. 0 means no extra timeout.
readPage := func() (*rod.Page, func()) {
if queryTimeout > 0 {
tCtx, cancel := context.WithTimeout(page.GetContext(), time.Duration(queryTimeout)*time.Millisecond)
return page.Context(tCtx), cancel
}
return page, func() {}
}
// pollUntil polls a JS condition (function expression) until it returns true,
// the page context is done, or the condition errors.
pollUntil := func(condJS string) error {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-page.GetContext().Done():
return page.GetContext().Err()
case <-ticker.C:
res, err := page.Eval(condJS)
if err != nil {
return err
}
if res.Value.Bool() {
return nil
}
}
}
}
// -------------------------------------------------------------------------
// Navigation
// -------------------------------------------------------------------------
pc.Set("navigate", func(call goja.FunctionCall) goja.Value {
rawURL := argStr(call.Argument(0))
dbg("→ navigate " + rawURL)
must(validateNavigateURL(rawURL))
must(page.Navigate(rawURL))
must(page.WaitLoad())
dbg("✓ navigate " + rawURL)
return goja.Undefined()
})
// navigateToHistoryOffset navigates by history offset without waiting for
// a load event (avoids deadlock on cached pages).
navigateToHistoryOffset := func(offset int) error {
res, err := proto.PageGetNavigationHistory{}.Call(page)
if err != nil {
return err
}
targetIndex := res.CurrentIndex + offset
if targetIndex < 0 || targetIndex >= len(res.Entries) {
return fmt.Errorf("no history entry at offset %d", offset)
}
targetEntry := res.Entries[targetIndex]
targetURL := targetEntry.URL
navCmd := proto.PageNavigateToHistoryEntry{EntryID: targetEntry.ID}
if err := navCmd.Call(page); err != nil {
return err
}
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
info, err := page.Info()
if err != nil {
return err
}
if info.URL == targetURL {
return nil
}
select {
case <-page.GetContext().Done():
return page.GetContext().Err()
case <-time.After(50 * time.Millisecond):
}
}
return fmt.Errorf("timed out waiting for navigation to %s", targetURL)
}
pc.Set("navigateBack", func(call goja.FunctionCall) goja.Value {
dbg("→ navigateBack")
must(navigateToHistoryOffset(-1))
dbg("✓ navigateBack")
return goja.Undefined()
})
pc.Set("navigateForward", func(call goja.FunctionCall) goja.Value {
dbg("→ navigateForward")
must(navigateToHistoryOffset(+1))
dbg("✓ navigateForward")
return goja.Undefined()
})
pc.Set("reload", func(call goja.FunctionCall) goja.Value {
dbg("→ reload")
must(page.Reload())
dbg("✓ reload")
return goja.Undefined()
})
pc.Set("stop", func(call goja.FunctionCall) goja.Value {
dbg("→ stop")
must(proto.PageStopLoading{}.Call(page))
dbg("✓ stop")
return goja.Undefined()
})
pc.Set("location", func(call goja.FunctionCall) goja.Value {
info, err := page.Info()
must(err)
dbg("= location " + info.URL)
return vm.ToValue(info.URL)
})
pc.Set("title", func(call goja.FunctionCall) goja.Value {
info, err := page.Info()
must(err)
dbg("= title " + info.Title)
return vm.ToValue(info.Title)
})
// -------------------------------------------------------------------------
// Waiting
// -------------------------------------------------------------------------
pc.Set("waitVisible", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("… waitVisible " + sel)
el, err := page.Element(sel)
must(err)
must(el.WaitVisible())
dbg("✓ waitVisible " + sel)
return goja.Undefined()
})
pc.Set("waitReady", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("… waitReady " + sel)
el, err := page.Element(sel)
must(err)
must(el.WaitVisible())
must(el.WaitEnabled())
dbg("✓ waitReady " + sel)
return goja.Undefined()
})
pc.Set("waitEnabled", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("… waitEnabled " + sel)
el, err := page.Element(sel)
must(err)
must(el.WaitEnabled())
dbg("✓ waitEnabled " + sel)
return goja.Undefined()
})
pc.Set("waitSelected", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("… waitSelected " + sel)
must(pollUntil(fmt.Sprintf("() => { const el = document.querySelector(%q); return !!el && el.selected }", sel)))
dbg("✓ waitSelected " + sel)
return goja.Undefined()
})
pc.Set("waitNotVisible", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("… waitNotVisible " + sel)
must(pollUntil(fmt.Sprintf("() => { const el = document.querySelector(%q); return !el || el.offsetParent === null }", sel)))
dbg("✓ waitNotVisible " + sel)
return goja.Undefined()
})
pc.Set("waitNotPresent", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("… waitNotPresent " + sel)
must(pollUntil(fmt.Sprintf("() => !document.querySelector(%q)", sel)))
dbg("✓ waitNotPresent " + sel)
return goja.Undefined()
})
// -------------------------------------------------------------------------
// Mouse
// -------------------------------------------------------------------------
pc.Set("click", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("→ click " + sel)
el, err := page.Element(sel)
must(err)
must(el.Click(proto.InputMouseButtonLeft, 1))
dbg("✓ click " + sel)
return goja.Undefined()
})
pc.Set("doubleClick", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("→ doubleClick " + sel)
el, err := page.Element(sel)
must(err)
must(el.Click(proto.InputMouseButtonLeft, 2))
dbg("✓ doubleClick " + sel)
return goja.Undefined()
})
pc.Set("clickXY", func(call goja.FunctionCall) goja.Value {
x := call.Argument(0).ToFloat()
y := call.Argument(1).ToFloat()
dbg(fmt.Sprintf("→ clickXY %.0f,%.0f", x, y))
must(page.Mouse.MoveTo(proto.Point{X: x, Y: y}))
must(page.Mouse.Click(proto.InputMouseButtonLeft, 1))
dbg(fmt.Sprintf("✓ clickXY %.0f,%.0f", x, y))
return goja.Undefined()
})
pc.Set("scrollIntoView", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("→ scrollIntoView " + sel)
el, err := page.Element(sel)
must(err)
must(el.ScrollIntoView())
dbg("✓ scrollIntoView " + sel)
return goja.Undefined()
})
// -------------------------------------------------------------------------
// Keyboard
// -------------------------------------------------------------------------
pc.Set("sendKeys", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
text := argStr(call.Argument(1))
dbg(fmt.Sprintf("→ sendKeys %s (%d chars)", sel, len(text)))
el, err := page.Element(sel)
must(err)
must(el.Focus())
must(page.InsertText(text))
dbg("✓ sendKeys " + sel)
return goja.Undefined()
})
// namedKeys maps CDP/browser key name strings to rod input.Key constants.
// For single-character keys the rune value is used directly as a fallback.
namedKeys := map[string]input.Key{
"Enter": input.Enter, "Return": input.Enter,
"Tab": input.Tab, "Escape": input.Escape, "Backspace": input.Backspace,
"Delete": input.Delete, "Insert": input.Insert,
"Home": input.Home, "End": input.End,
"PageUp": input.PageUp, "PageDown": input.PageDown,
"ArrowLeft": input.ArrowLeft, "ArrowRight": input.ArrowRight,
"ArrowUp": input.ArrowUp, "ArrowDown": input.ArrowDown,
" ": input.Space, "Space": input.Space,
"F1": input.F1, "F2": input.F2, "F3": input.F3, "F4": input.F4,
"F5": input.F5, "F6": input.F6, "F7": input.F7, "F8": input.F8,
"F9": input.F9, "F10": input.F10, "F11": input.F11, "F12": input.F12,
"ShiftLeft": input.ShiftLeft, "ShiftRight": input.ShiftRight,
"ControlLeft": input.ControlLeft, "ControlRight": input.ControlRight,
"AltLeft": input.AltLeft, "AltRight": input.AltRight,
}
pc.Set("keyEvent", func(call goja.FunctionCall) goja.Value {
key := argStr(call.Argument(0))
dbg("→ keyEvent " + key)
if k, ok := namedKeys[key]; ok {
must(page.Keyboard.Press(k))
} else {
runes := []rune(key)
if len(runes) > 0 {
must(page.Keyboard.Press(input.Key(runes[0])))
}
}
dbg("✓ keyEvent " + key)
return goja.Undefined()
})
// -------------------------------------------------------------------------
// Form
// -------------------------------------------------------------------------
pc.Set("clear", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("→ clear " + sel)
script := fmt.Sprintf(`() => {
var el = document.querySelector(%q);
if (!el) return;
var nativeInput = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')
|| Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value');
if (nativeInput && nativeInput.set) {
nativeInput.set.call(el, '');
} else {
el.value = '';
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}`, sel)
_, err := page.Eval(script)
must(err)
dbg("✓ clear " + sel)
return goja.Undefined()
})
pc.Set("focus", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("→ focus " + sel)
el, err := page.Element(sel)
must(err)
must(el.Focus())
dbg("✓ focus " + sel)
return goja.Undefined()
})
pc.Set("blur", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("→ blur " + sel)
_, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el.blur() }", sel))
must(err)
dbg("✓ blur " + sel)
return goja.Undefined()
})
pc.Set("submit", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("→ submit " + sel)
_, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el.submit() }", sel))
must(err)
dbg("✓ submit " + sel)
return goja.Undefined()
})
pc.Set("setValue", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
val := argStr(call.Argument(1))
dbg(fmt.Sprintf("→ setValue %s = %q", sel, val))
el, err := page.Element(sel)
must(err)
must(el.Input(val))
dbg("✓ setValue " + sel)
return goja.Undefined()
})
pc.Set("getValue", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("? getValue " + sel)
rPage, rCancel := readPage()
defer rCancel()
el, err := rPage.Element(sel)
must(err)
prop, err := el.Property("value")
must(err)
val := prop.Str()
dbg(fmt.Sprintf("= getValue %s %q", sel, val))
return vm.ToValue(val)
})
// -------------------------------------------------------------------------
// DOM reading
// -------------------------------------------------------------------------
pc.Set("getText", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("? getText " + sel)
rPage, rCancel := readPage()
defer rCancel()
el, err := rPage.Element(sel)
must(err)
text, err := el.Text()
must(err)
dbg(fmt.Sprintf("= getText %s %q", sel, text))
return vm.ToValue(text)
})
pc.Set("getTextContent", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("? getTextContent " + sel)
rPage, rCancel := readPage()
defer rCancel()
el, err := rPage.Element(sel)
must(err)
res, err := el.Eval("() => this.textContent")
must(err)
text := res.Value.Str()
dbg(fmt.Sprintf("= getTextContent %s %q", sel, text))
return vm.ToValue(text)
})
pc.Set("getInnerHTML", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("? getInnerHTML " + sel)
rPage, rCancel := readPage()
defer rCancel()
el, err := rPage.Element(sel)
must(err)
res, err := el.Eval("() => this.innerHTML")
must(err)
html := res.Value.Str()
dbg(fmt.Sprintf("= getInnerHTML %s (%d bytes)", sel, len(html)))
return vm.ToValue(html)
})
pc.Set("getOuterHTML", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("? getOuterHTML " + sel)
rPage, rCancel := readPage()
defer rCancel()
el, err := rPage.Element(sel)
must(err)
html, err := el.HTML()
must(err)
dbg(fmt.Sprintf("= getOuterHTML %s (%d bytes)", sel, len(html)))
return vm.ToValue(html)
})
pc.Set("getAttribute", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
attr := argStr(call.Argument(1))
dbg(fmt.Sprintf("? getAttribute %s[%s]", sel, attr))
rPage, rCancel := readPage()
defer rCancel()
el, err := rPage.Element(sel)
must(err)
val, err := el.Attribute(attr)
must(err)
if val == nil {
dbg(fmt.Sprintf("= getAttribute %s[%s] null", sel, attr))
return goja.Null()
}
dbg(fmt.Sprintf("= getAttribute %s[%s] %q", sel, attr, *val))
return vm.ToValue(*val)
})
pc.Set("getAttributes", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("? getAttributes " + sel)
rPage, rCancel := readPage()
defer rCancel()
res, err := rPage.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if (!el) return {}; return Object.fromEntries([...el.attributes].map(a => [a.name, a.value])) }", sel))
must(err)
dbg(fmt.Sprintf("= getAttributes %s", sel))
return vm.ToValue(res.Value.Val())
})
pc.Set("setAttribute", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
attr := argStr(call.Argument(1))
val := argStr(call.Argument(2))
dbg(fmt.Sprintf("→ setAttribute %s[%s] = %q", sel, attr, val))
_, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el.setAttribute(%q, %q) }", sel, attr, val))
must(err)
dbg(fmt.Sprintf("✓ setAttribute %s[%s]", sel, attr))
return goja.Undefined()
})
pc.Set("removeAttribute", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
attr := argStr(call.Argument(1))
dbg(fmt.Sprintf("→ removeAttribute %s[%s]", sel, attr))
_, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el.removeAttribute(%q) }", sel, attr))
must(err)
dbg(fmt.Sprintf("✓ removeAttribute %s[%s]", sel, attr))
return goja.Undefined()
})
pc.Set("getJSAttribute", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
attr := argStr(call.Argument(1))
dbg(fmt.Sprintf("? getJSAttribute %s.%s", sel, attr))
rPage, rCancel := readPage()
defer rCancel()
el, err := rPage.Element(sel)
must(err)
prop, err := el.Property(attr)
must(err)
dbg(fmt.Sprintf("= getJSAttribute %s.%s %v", sel, attr, prop.Val()))
return vm.ToValue(prop.Val())
})
pc.Set("setJSAttribute", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
attr := argStr(call.Argument(1))
val := argStr(call.Argument(2))
dbg(fmt.Sprintf("→ setJSAttribute %s.%s = %q", sel, attr, val))
_, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el[%q] = %q }", sel, attr, val))
must(err)
dbg(fmt.Sprintf("✓ setJSAttribute %s.%s", sel, attr))
return goja.Undefined()
})
pc.Set("getNodeCount", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
dbg("? getNodeCount " + sel)
rPage, rCancel := readPage()
defer rCancel()
els, err := rPage.Elements(sel)
if err != nil {
return vm.ToValue(0)
}
dbg(fmt.Sprintf("= getNodeCount %s %d", sel, len(els)))
return vm.ToValue(len(els))
})
// -------------------------------------------------------------------------
// JavaScript evaluation
// -------------------------------------------------------------------------
pc.Set("evaluate", func(call goja.FunctionCall) goja.Value {
dbg("→ evaluate")
expr := argStr(call.Argument(0))
rPage, rCancel := readPage()
defer rCancel()
res, err := proto.RuntimeEvaluate{Expression: expr}.Call(rPage)
must(err)
result := res.Result.Value.Val()
dbg(fmt.Sprintf("= evaluate %v", result))
return vm.ToValue(result)
})
// -------------------------------------------------------------------------
// Screenshots
// -------------------------------------------------------------------------
pc.Set("screenshot", func(call goja.FunctionCall) goja.Value {
name := argStr(call.Argument(0))
dbg("→ screenshot " + name)
rPage, rCancel := readPage()
defer rCancel()
info, _ := rPage.Info()
buf, err := rPage.Screenshot(true, nil)
if err != nil {
if emitter != nil {
emitter.log(fmt.Sprintf("[screenshot] %s: %s", name, err))
}
return goja.Undefined()
}
pageURL := ""
if info != nil {
pageURL = info.URL
}
if emitter != nil {
emitter.screenshot(name, buf, pageURL)
}
dbg("✓ screenshot " + name)
return goja.Undefined()
})
pc.Set("screenshotElement", func(call goja.FunctionCall) goja.Value {
sel := argStr(call.Argument(0))
name := argStr(call.Argument(1))
dbg(fmt.Sprintf("→ screenshotElement %s as %s", sel, name))
rPage, rCancel := readPage()
defer rCancel()
info, _ := rPage.Info()
el, err := rPage.Element(sel)
must(err)
must(el.WaitVisible())
buf, err := el.Screenshot("", 0)
if err != nil {
if emitter != nil {
emitter.log(fmt.Sprintf("[screenshot] %s: %s", name, err))
}
return goja.Undefined()
}
pageURL := ""
if info != nil {
pageURL = info.URL
}
if emitter != nil {
emitter.screenshot(name, buf, pageURL)
}
dbg("✓ screenshotElement " + name)
return goja.Undefined()
})
// -------------------------------------------------------------------------
// Viewport & emulation
// -------------------------------------------------------------------------
pc.Set("setViewport", func(call goja.FunctionCall) goja.Value {
w := call.Argument(0).ToInteger()
h := call.Argument(1).ToInteger()
dbg(fmt.Sprintf("→ setViewport %dx%d", w, h))
must(proto.EmulationSetDeviceMetricsOverride{
Width: int(w), Height: int(h), DeviceScaleFactor: 1,
}.Call(page))
dbg(fmt.Sprintf("✓ setViewport %dx%d", w, h))
return goja.Undefined()
})
pc.Set("setViewportMobile", func(call goja.FunctionCall) goja.Value {
w := call.Argument(0).ToInteger()
h := call.Argument(1).ToInteger()
dbg(fmt.Sprintf("→ setViewportMobile %dx%d", w, h))
must(proto.EmulationSetDeviceMetricsOverride{
Width: int(w), Height: int(h), DeviceScaleFactor: 1,
Mobile: true,
}.Call(page))
must(proto.EmulationSetTouchEmulationEnabled{Enabled: true}.Call(page))
dbg(fmt.Sprintf("✓ setViewportMobile %dx%d", w, h))
return goja.Undefined()
})
pc.Set("resetViewport", func(call goja.FunctionCall) goja.Value {
dbg("→ resetViewport")
must(proto.EmulationClearDeviceMetricsOverride{}.Call(page))
dbg("✓ resetViewport")
return goja.Undefined()
})
pc.Set("setUserAgent", func(call goja.FunctionCall) goja.Value {
ua := argStr(call.Argument(0))
dbg("→ setUserAgent " + ua)
must(proto.EmulationSetUserAgentOverride{UserAgent: ua}.Call(page))
dbg("✓ setUserAgent")
return goja.Undefined()
})
// -------------------------------------------------------------------------
// Utility
// -------------------------------------------------------------------------
pc.Set("sleep", func(call goja.FunctionCall) goja.Value {
ms := call.Argument(0).ToInteger()
dbg(fmt.Sprintf("→ sleep %dms", ms))
select {
case <-page.GetContext().Done():
must(page.GetContext().Err())
case <-time.After(time.Duration(ms) * time.Millisecond):
}
dbg(fmt.Sprintf("✓ sleep %dms", ms))
return goja.Undefined()
})
// disableFidoUI enables the CDP WebAuthn virtual authenticator environment.
// In this mode Chrome intercepts WebAuthn/FIDO requests via CDP instead of
// showing the native "Passkeys & Security Keys" browser dialog, so DOM
// interactions remain possible while on the FIDO page.
pc.Set("disableFidoUI", func(call goja.FunctionCall) goja.Value {
dbg("→ disableFidoUI")
must(proto.WebAuthnEnable{}.Call(page))
dbg("✓ disableFidoUI")
return goja.Undefined()
})
}