gui/pkg/webview/service.go
Snider 62ec735c10
Some checks failed
Security Scan / security (push) Has been cancelled
Test / test (push) Has been cancelled
refactor: AX compliance sweep — replace banned stdlib imports with core primitives
Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath,
errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim,
core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(),
core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives.

Framework boundary exceptions preserved where stdlib types are required
by external interfaces (Gin, net/http, CGo, Wails, bubbletea).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-13 09:32:01 +01:00

781 lines
21 KiB
Go

// pkg/webview/service.go
package webview
import (
"bytes"
"context"
"encoding/base64"
"image"
"image/draw"
"image/png"
"math"
"reflect"
"strconv"
"sync"
"time"
"unsafe"
corego "dappco.re/go/core"
"dappco.re/go/core/gui/pkg/window"
gowebview "forge.lthn.ai/core/go-webview"
"forge.lthn.ai/core/go/pkg/core"
)
// connector abstracts go-webview for testing. The real implementation wraps
// *gowebview.Webview, converting go-webview types to our own types at the boundary.
type connector interface {
Navigate(url string) error
Click(selector string) error
Type(selector, text string) error
Hover(selector string) error
Select(selector, value string) error
Check(selector string, checked bool) error
Evaluate(script string) (any, error)
Screenshot() ([]byte, error)
GetURL() (string, error)
GetTitle() (string, error)
GetHTML(selector string) (string, error)
QuerySelector(selector string) (*ElementInfo, error)
QuerySelectorAll(selector string) ([]*ElementInfo, error)
GetConsole() []ConsoleMessage
ClearConsole()
SetViewport(width, height int) error
UploadFile(selector string, paths []string) error
Print() error
PrintToPDF() ([]byte, error)
Close() error
}
// Options holds configuration for the webview service.
// Use: svc, err := webview.Register(webview.Options{})(core.New())
type Options struct {
DebugURL string // Chrome debug endpoint (default: "http://localhost:9222")
Timeout time.Duration // Operation timeout (default: 30s)
ConsoleLimit int // Max console messages per window (default: 1000)
}
// Service is a core.Service managing webview interactions via IPC.
// Use: svc, err := webview.Register(webview.Options{})(core.New())
type Service struct {
*core.ServiceRuntime[Options]
opts Options
connections map[string]connector
exceptions map[string][]ExceptionInfo
mu sync.RWMutex
newConn func(debugURL, windowName string) (connector, error) // injectable for tests
watcherSetup func(conn connector, windowName string) // called after connection creation
}
// Register creates a factory closure with declarative options.
// Use: core.WithService(webview.Register(webview.Options{ConsoleLimit: 500}))
func Register(options Options) func(*core.Core) (any, error) {
o := Options{
DebugURL: "http://localhost:9222",
Timeout: 30 * time.Second,
ConsoleLimit: 1000,
}
if options.DebugURL != "" {
o.DebugURL = options.DebugURL
}
if options.Timeout != 0 {
o.Timeout = options.Timeout
}
if options.ConsoleLimit != 0 {
o.ConsoleLimit = options.ConsoleLimit
}
return func(c *core.Core) (any, error) {
svc := &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, o),
opts: o,
connections: make(map[string]connector),
exceptions: make(map[string][]ExceptionInfo),
newConn: defaultNewConn(o),
}
svc.watcherSetup = svc.defaultWatcherSetup
return svc, nil
}
}
// defaultNewConn creates real go-webview connections.
func defaultNewConn(opts Options) func(string, string) (connector, error) {
return func(debugURL, windowName string) (connector, error) {
// Enumerate targets, match by title/URL containing window name
targets, err := gowebview.ListTargets(debugURL)
if err != nil {
return nil, err
}
var wsURL string
for _, t := range targets {
if t.Type == "page" && (corego.Contains(t.Title, windowName) || corego.Contains(t.URL, windowName)) {
wsURL = t.WebSocketDebuggerURL
break
}
}
// Fallback: first page target
if wsURL == "" {
for _, t := range targets {
if t.Type == "page" {
wsURL = t.WebSocketDebuggerURL
break
}
}
}
if wsURL == "" {
return nil, core.E("webview.connect", "no page target found", nil)
}
wv, err := gowebview.New(
gowebview.WithDebugURL(debugURL),
gowebview.WithTimeout(opts.Timeout),
gowebview.WithConsoleLimit(opts.ConsoleLimit),
)
if err != nil {
return nil, err
}
return &realConnector{wv: wv}, nil
}
}
// defaultWatcherSetup wires up console/exception watchers on real connectors.
// It broadcasts ActionConsoleMessage and ActionException via the Core IPC bus.
func (s *Service) defaultWatcherSetup(conn connector, windowName string) {
rc, ok := conn.(*realConnector)
if !ok {
return // test mocks don't need watchers
}
cw := gowebview.NewConsoleWatcher(rc.wv)
cw.AddHandler(func(msg gowebview.ConsoleMessage) {
_ = s.Core().ACTION(ActionConsoleMessage{
Window: windowName,
Message: ConsoleMessage{
Type: msg.Type,
Text: msg.Text,
Timestamp: msg.Timestamp,
URL: msg.URL,
Line: msg.Line,
Column: msg.Column,
},
})
})
ew := gowebview.NewExceptionWatcher(rc.wv)
ew.AddHandler(func(exc gowebview.ExceptionInfo) {
_ = s.Core().ACTION(ActionException{
Window: windowName,
Exception: ExceptionInfo{
Text: exc.Text,
URL: exc.URL,
Line: exc.LineNumber,
Column: exc.ColumnNumber,
StackTrace: exc.StackTrace,
Timestamp: exc.Timestamp,
},
})
})
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(_ context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
// OnShutdown closes all CDP connections.
func (s *Service) OnShutdown(_ context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
for name, conn := range s.connections {
conn.Close()
delete(s.connections, name)
}
return nil
}
// HandleIPCEvents listens for window close events to clean up connections.
func (s *Service) HandleIPCEvents(_ *core.Core, msg core.Message) error {
switch m := msg.(type) {
case window.ActionWindowClosed:
s.mu.Lock()
if conn, ok := s.connections[m.Name]; ok {
conn.Close()
delete(s.connections, m.Name)
}
delete(s.exceptions, m.Name)
s.mu.Unlock()
case ActionException:
s.recordException(m.Window, m.Exception)
}
return nil
}
// getConn returns the connector for a window, creating it if needed.
func (s *Service) getConn(windowName string) (connector, error) {
s.mu.RLock()
if conn, ok := s.connections[windowName]; ok {
s.mu.RUnlock()
return conn, nil
}
s.mu.RUnlock()
s.mu.Lock()
defer s.mu.Unlock()
// Double-check after acquiring write lock
if conn, ok := s.connections[windowName]; ok {
return conn, nil
}
conn, err := s.newConn(s.opts.DebugURL, windowName)
if err != nil {
return nil, err
}
s.connections[windowName] = conn
if s.watcherSetup != nil {
s.watcherSetup(conn, windowName)
}
return conn, nil
}
func (s *Service) handleQuery(_ *core.Core, q core.Query) (any, bool, error) {
switch q := q.(type) {
case QueryURL:
conn, err := s.getConn(q.Window)
if err != nil {
return nil, true, err
}
url, err := conn.GetURL()
return url, true, err
case QueryTitle:
conn, err := s.getConn(q.Window)
if err != nil {
return nil, true, err
}
title, err := conn.GetTitle()
return title, true, err
case QueryConsole:
conn, err := s.getConn(q.Window)
if err != nil {
return nil, true, err
}
msgs := conn.GetConsole()
// Filter by level if specified
if q.Level != "" {
var filtered []ConsoleMessage
for _, m := range msgs {
if m.Type == q.Level {
filtered = append(filtered, m)
}
}
msgs = filtered
}
// Apply limit
if q.Limit > 0 && len(msgs) > q.Limit {
msgs = msgs[len(msgs)-q.Limit:]
}
return msgs, true, nil
case QuerySelector:
conn, err := s.getConn(q.Window)
if err != nil {
return nil, true, err
}
el, err := conn.QuerySelector(q.Selector)
return el, true, err
case QuerySelectorAll:
conn, err := s.getConn(q.Window)
if err != nil {
return nil, true, err
}
els, err := conn.QuerySelectorAll(q.Selector)
return els, true, err
case QueryDOMTree:
conn, err := s.getConn(q.Window)
if err != nil {
return nil, true, err
}
selector := q.Selector
if selector == "" {
selector = "html"
}
html, err := conn.GetHTML(selector)
return html, true, err
case QueryComputedStyle:
conn, err := s.getConn(q.Window)
if err != nil {
return nil, true, err
}
result, err := conn.Evaluate(computedStyleScript(q.Selector))
if err != nil {
return nil, true, err
}
style, err := coerceToMapStringString(result)
if err != nil {
return nil, true, err
}
return style, true, nil
case QueryPerformance:
conn, err := s.getConn(q.Window)
if err != nil {
return nil, true, err
}
result, err := conn.Evaluate(performanceScript())
if err != nil {
return nil, true, err
}
metrics, err := coerceToPerformanceMetrics(result)
if err != nil {
return nil, true, err
}
return metrics, true, nil
case QueryResources:
conn, err := s.getConn(q.Window)
if err != nil {
return nil, true, err
}
result, err := conn.Evaluate(resourcesScript())
if err != nil {
return nil, true, err
}
resources, err := coerceToResourceEntries(result)
if err != nil {
return nil, true, err
}
return resources, true, nil
case QueryNetwork:
conn, err := s.getConn(q.Window)
if err != nil {
return nil, true, err
}
result, err := conn.Evaluate(networkLogScript(q.Limit))
if err != nil {
return nil, true, err
}
entries, err := coerceToNetworkEntries(result)
if err != nil {
return nil, true, err
}
return entries, true, nil
case QueryExceptions:
return s.queryExceptions(q.Window, q.Limit), true, nil
default:
return nil, false, nil
}
}
func (s *Service) handleTask(_ *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskEvaluate:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
result, err := conn.Evaluate(t.Script)
return result, true, err
case TaskClick:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
return nil, true, conn.Click(t.Selector)
case TaskType:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
return nil, true, conn.Type(t.Selector, t.Text)
case TaskNavigate:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
return nil, true, conn.Navigate(t.URL)
case TaskScreenshot:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
png, err := conn.Screenshot()
if err != nil {
return nil, true, err
}
return ScreenshotResult{
Base64: base64.StdEncoding.EncodeToString(png),
MimeType: "image/png",
}, true, nil
case TaskScreenshotElement:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
png, err := captureElementScreenshot(conn, t.Selector)
if err != nil {
return nil, true, err
}
return ScreenshotResult{
Base64: base64.StdEncoding.EncodeToString(png),
MimeType: "image/png",
}, true, nil
case TaskScroll:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
_, err = conn.Evaluate("window.scrollTo(" + strconv.Itoa(t.X) + "," + strconv.Itoa(t.Y) + ")")
return nil, true, err
case TaskHover:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
return nil, true, conn.Hover(t.Selector)
case TaskSelect:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
return nil, true, conn.Select(t.Selector, t.Value)
case TaskCheck:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
return nil, true, conn.Check(t.Selector, t.Checked)
case TaskUploadFile:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
return nil, true, conn.UploadFile(t.Selector, t.Paths)
case TaskSetViewport:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
return nil, true, conn.SetViewport(t.Width, t.Height)
case TaskClearConsole:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
conn.ClearConsole()
return nil, true, nil
case TaskHighlight:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
_, err = conn.Evaluate(highlightScript(t.Selector, t.Colour))
return nil, true, err
case TaskOpenDevTools:
ws, err := core.ServiceFor[*window.Service](s.Core(), "window")
if err != nil {
return nil, true, err
}
pw, ok := ws.Manager().Get(t.Window)
if !ok {
return nil, true, corego.E("webview", corego.Sprintf("window not found: %s", t.Window), nil)
}
pw.OpenDevTools()
return nil, true, nil
case TaskCloseDevTools:
ws, err := core.ServiceFor[*window.Service](s.Core(), "window")
if err != nil {
return nil, true, err
}
pw, ok := ws.Manager().Get(t.Window)
if !ok {
return nil, true, corego.E("webview", corego.Sprintf("window not found: %s", t.Window), nil)
}
pw.CloseDevTools()
return nil, true, nil
case TaskInjectNetworkLogging:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
_, err = conn.Evaluate(networkInitScript())
return nil, true, err
case TaskClearNetworkLog:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
_, err = conn.Evaluate(networkClearScript())
return nil, true, err
case TaskPrint:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
return nil, true, conn.Print()
case TaskExportPDF:
conn, err := s.getConn(t.Window)
if err != nil {
return nil, true, err
}
pdf, err := conn.PrintToPDF()
if err != nil {
return nil, true, err
}
return PDFResult{
Base64: base64.StdEncoding.EncodeToString(pdf),
MimeType: "application/pdf",
}, true, nil
default:
return nil, false, nil
}
}
func (s *Service) recordException(windowName string, exc ExceptionInfo) {
s.mu.Lock()
defer s.mu.Unlock()
exceptions := append(s.exceptions[windowName], exc)
if limit := s.opts.ConsoleLimit; limit > 0 && len(exceptions) > limit {
exceptions = exceptions[len(exceptions)-limit:]
}
s.exceptions[windowName] = exceptions
}
func (s *Service) queryExceptions(windowName string, limit int) []ExceptionInfo {
s.mu.RLock()
defer s.mu.RUnlock()
exceptions := append([]ExceptionInfo(nil), s.exceptions[windowName]...)
if limit > 0 && len(exceptions) > limit {
exceptions = exceptions[len(exceptions)-limit:]
}
return exceptions
}
func coerceJSON[T any](v any) (T, error) {
var out T
r := corego.JSONMarshal(v)
if !r.OK {
return out, r.Value.(error)
}
r2 := corego.JSONUnmarshal(r.Value.([]byte), &out)
if !r2.OK {
return out, r2.Value.(error)
}
return out, nil
}
func coerceToMapStringString(v any) (map[string]string, error) {
return coerceJSON[map[string]string](v)
}
func coerceToPerformanceMetrics(v any) (PerformanceMetrics, error) {
return coerceJSON[PerformanceMetrics](v)
}
func coerceToResourceEntries(v any) ([]ResourceEntry, error) {
return coerceJSON[[]ResourceEntry](v)
}
func coerceToNetworkEntries(v any) ([]NetworkEntry, error) {
return coerceJSON[[]NetworkEntry](v)
}
type elementScreenshotBounds struct {
Left float64 `json:"left"`
Top float64 `json:"top"`
Width float64 `json:"width"`
Height float64 `json:"height"`
DevicePixelRatio float64 `json:"devicePixelRatio"`
}
func elementScreenshotScript(selector string) string {
sel := jsQuote(selector)
return corego.Sprintf(`(function(){
const el = document.querySelector(%s);
if (!el) return null;
try { el.scrollIntoView({block: "center", inline: "center"}); } catch (e) {}
const rect = el.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
devicePixelRatio: window.devicePixelRatio || 1
};
})()`, sel)
}
func captureElementScreenshot(conn connector, selector string) ([]byte, error) {
result, err := conn.Evaluate(elementScreenshotScript(selector))
if err != nil {
return nil, err
}
if result == nil {
return nil, corego.E("webview", corego.Sprintf("element not found: %s", selector), nil)
}
bounds, err := coerceJSON[elementScreenshotBounds](result)
if err != nil {
return nil, err
}
if bounds.Width <= 0 || bounds.Height <= 0 {
return nil, corego.E("webview", corego.Sprintf("element has no measurable bounds: %s", selector), nil)
}
raw, err := conn.Screenshot()
if err != nil {
return nil, err
}
img, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, err
}
scale := bounds.DevicePixelRatio
if scale <= 0 {
scale = 1
}
left := int(math.Floor(bounds.Left * scale))
top := int(math.Floor(bounds.Top * scale))
right := int(math.Ceil((bounds.Left + bounds.Width) * scale))
bottom := int(math.Ceil((bounds.Top + bounds.Height) * scale))
srcBounds := img.Bounds()
if left < srcBounds.Min.X {
left = srcBounds.Min.X
}
if top < srcBounds.Min.Y {
top = srcBounds.Min.Y
}
if right > srcBounds.Max.X {
right = srcBounds.Max.X
}
if bottom > srcBounds.Max.Y {
bottom = srcBounds.Max.Y
}
if right <= left || bottom <= top {
return nil, corego.E("webview", corego.Sprintf("element is outside the captured screenshot: %s", selector), nil)
}
crop := image.NewRGBA(image.Rect(0, 0, right-left, bottom-top))
draw.Draw(crop, crop.Bounds(), img, image.Point{X: left, Y: top}, draw.Src)
var buf bytes.Buffer
if err := png.Encode(&buf, crop); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// realConnector wraps *gowebview.Webview, converting types at the boundary.
type realConnector struct {
wv *gowebview.Webview
}
func (r *realConnector) Navigate(url string) error { return r.wv.Navigate(url) }
func (r *realConnector) Click(sel string) error { return r.wv.Click(sel) }
func (r *realConnector) Type(sel, text string) error { return r.wv.Type(sel, text) }
func (r *realConnector) Evaluate(script string) (any, error) { return r.wv.Evaluate(script) }
func (r *realConnector) Screenshot() ([]byte, error) { return r.wv.Screenshot() }
func (r *realConnector) GetURL() (string, error) { return r.wv.GetURL() }
func (r *realConnector) GetTitle() (string, error) { return r.wv.GetTitle() }
func (r *realConnector) GetHTML(sel string) (string, error) { return r.wv.GetHTML(sel) }
func (r *realConnector) ClearConsole() { r.wv.ClearConsole() }
func (r *realConnector) Print() error { _, err := r.wv.Evaluate("window.print()"); return err }
func (r *realConnector) Close() error { return r.wv.Close() }
func (r *realConnector) SetViewport(w, h int) error { return r.wv.SetViewport(w, h) }
func (r *realConnector) UploadFile(sel string, p []string) error { return r.wv.UploadFile(sel, p) }
func (r *realConnector) PrintToPDF() ([]byte, error) {
client, err := r.cdpClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := client.Call(ctx, "Page.printToPDF", map[string]any{
"printBackground": true,
"preferCSSPageSize": true,
})
if err != nil {
return nil, err
}
data, ok := result["data"].(string)
if !ok || data == "" {
return nil, corego.E("webview", "missing PDF data", nil)
}
return base64.StdEncoding.DecodeString(data)
}
func (r *realConnector) cdpClient() (*gowebview.CDPClient, error) {
rv := reflect.ValueOf(r.wv)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return nil, corego.E("webview", "invalid connector", nil)
}
elem := rv.Elem()
field := elem.FieldByName("client")
if !field.IsValid() || field.IsNil() {
return nil, corego.E("webview", "CDP client not available", nil)
}
ptr := reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Interface()
client, ok := ptr.(*gowebview.CDPClient)
if !ok || client == nil {
return nil, corego.E("webview", "unexpected CDP client type", nil)
}
return client, nil
}
func (r *realConnector) Hover(sel string) error {
return gowebview.NewActionSequence().Add(&gowebview.HoverAction{Selector: sel}).Execute(context.Background(), r.wv)
}
func (r *realConnector) Select(sel, val string) error {
return gowebview.NewActionSequence().Add(&gowebview.SelectAction{Selector: sel, Value: val}).Execute(context.Background(), r.wv)
}
func (r *realConnector) Check(sel string, checked bool) error {
return gowebview.NewActionSequence().Add(&gowebview.CheckAction{Selector: sel, Checked: checked}).Execute(context.Background(), r.wv)
}
func (r *realConnector) QuerySelector(sel string) (*ElementInfo, error) {
el, err := r.wv.QuerySelector(sel)
if err != nil {
return nil, err
}
return convertElementInfo(el), nil
}
func (r *realConnector) QuerySelectorAll(sel string) ([]*ElementInfo, error) {
els, err := r.wv.QuerySelectorAll(sel)
if err != nil {
return nil, err
}
result := make([]*ElementInfo, len(els))
for i, el := range els {
result[i] = convertElementInfo(el)
}
return result, nil
}
func (r *realConnector) GetConsole() []ConsoleMessage {
raw := r.wv.GetConsole()
msgs := make([]ConsoleMessage, len(raw))
for i, m := range raw {
msgs[i] = ConsoleMessage{
Type: m.Type, Text: m.Text, Timestamp: m.Timestamp,
URL: m.URL, Line: m.Line, Column: m.Column,
}
}
return msgs
}
func convertElementInfo(el *gowebview.ElementInfo) *ElementInfo {
if el == nil {
return nil
}
info := &ElementInfo{
TagName: el.TagName,
Attributes: el.Attributes,
InnerText: el.InnerText,
InnerHTML: el.InnerHTML,
}
if el.BoundingBox != nil {
info.BoundingBox = &BoundingBox{
X: el.BoundingBox.X, Y: el.BoundingBox.Y,
Width: el.BoundingBox.Width, Height: el.BoundingBox.Height,
}
}
return info
}