feat(webview): add service with connector interface and IPC handlers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
465ef415a3
commit
67a364cf4e
1 changed files with 405 additions and 0 deletions
405
pkg/webview/service.go
Normal file
405
pkg/webview/service.go
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
// pkg/webview/service.go
|
||||
package webview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gowebview "forge.lthn.ai/core/go-webview"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"forge.lthn.ai/core/gui/pkg/window"
|
||||
)
|
||||
|
||||
// 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
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Options holds configuration for the webview service.
|
||||
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.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
opts Options
|
||||
connections map[string]connector
|
||||
mu sync.RWMutex
|
||||
newConn func(debugURL, windowName string) (connector, error) // injectable for tests
|
||||
}
|
||||
|
||||
// Register creates a factory closure with the given options.
|
||||
func Register(opts ...func(*Options)) func(*core.Core) (any, error) {
|
||||
o := Options{
|
||||
DebugURL: "http://localhost:9222",
|
||||
Timeout: 30 * time.Second,
|
||||
ConsoleLimit: 1000,
|
||||
}
|
||||
for _, fn := range opts {
|
||||
fn(&o)
|
||||
}
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime[Options](c, o),
|
||||
opts: o,
|
||||
connections: make(map[string]connector),
|
||||
newConn: defaultNewConn(o),
|
||||
}, 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" && (strings.Contains(t.Title, windowName) || strings.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
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
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
|
||||
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
|
||||
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 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
|
||||
default:
|
||||
return nil, false, 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) 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) 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue