2026-03-23 07:34:16 +00:00
|
|
|
// SPDX-License-Identifier: EUPL-1.2
|
2026-02-19 16:09:11 +00:00
|
|
|
package webview
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"io"
|
2026-02-23 05:26:39 +00:00
|
|
|
"iter"
|
2026-03-23 07:34:16 +00:00
|
|
|
"net"
|
2026-02-19 16:09:11 +00:00
|
|
|
"net/http"
|
2026-03-23 07:34:16 +00:00
|
|
|
"net/url"
|
|
|
|
|
"path"
|
2026-02-23 05:26:39 +00:00
|
|
|
"slices"
|
2026-03-23 07:34:16 +00:00
|
|
|
"strings"
|
2026-02-19 16:09:11 +00:00
|
|
|
"sync"
|
|
|
|
|
"sync/atomic"
|
2026-03-23 07:34:16 +00:00
|
|
|
"time"
|
2026-02-19 16:09:11 +00:00
|
|
|
|
2026-03-26 13:53:43 +00:00
|
|
|
core "dappco.re/go/core"
|
2026-03-21 23:45:30 +00:00
|
|
|
coreerr "dappco.re/go/core/log"
|
2026-03-26 13:53:43 +00:00
|
|
|
|
|
|
|
|
"github.com/gorilla/websocket"
|
2026-02-19 16:09:11 +00:00
|
|
|
)
|
|
|
|
|
|
2026-03-23 07:34:16 +00:00
|
|
|
const debugEndpointTimeout = 10 * time.Second
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
defaultDebugHTTPClient = &http.Client{
|
|
|
|
|
Timeout: debugEndpointTimeout,
|
|
|
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
|
|
|
return http.ErrUseLastResponse
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-03-26 13:53:43 +00:00
|
|
|
errCDPClientClosed = core.NewError("cdp client closed")
|
2026-03-23 07:34:16 +00:00
|
|
|
)
|
|
|
|
|
|
2026-02-19 16:09:11 +00:00
|
|
|
// CDPClient handles communication with Chrome DevTools Protocol via WebSocket.
|
|
|
|
|
type CDPClient struct {
|
2026-03-23 07:34:16 +00:00
|
|
|
mu sync.RWMutex
|
|
|
|
|
conn *websocket.Conn
|
|
|
|
|
debugURL string
|
|
|
|
|
debugBase *url.URL
|
|
|
|
|
wsURL string
|
2026-02-19 16:09:11 +00:00
|
|
|
|
|
|
|
|
// Message tracking
|
|
|
|
|
msgID atomic.Int64
|
|
|
|
|
pending map[int64]chan *cdpResponse
|
|
|
|
|
pendMu sync.Mutex
|
|
|
|
|
|
|
|
|
|
// Event handlers
|
|
|
|
|
handlers map[string][]func(map[string]any)
|
|
|
|
|
handMu sync.RWMutex
|
|
|
|
|
|
|
|
|
|
// Lifecycle
|
2026-03-23 07:34:16 +00:00
|
|
|
ctx context.Context
|
|
|
|
|
cancel context.CancelFunc
|
|
|
|
|
done chan struct{}
|
|
|
|
|
closeOnce sync.Once
|
|
|
|
|
closeErr error
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cdpMessage represents a CDP protocol message.
|
|
|
|
|
type cdpMessage struct {
|
|
|
|
|
ID int64 `json:"id,omitempty"`
|
|
|
|
|
Method string `json:"method"`
|
|
|
|
|
Params map[string]any `json:"params,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cdpResponse represents a CDP protocol response.
|
|
|
|
|
type cdpResponse struct {
|
|
|
|
|
ID int64 `json:"id"`
|
|
|
|
|
Result map[string]any `json:"result,omitempty"`
|
|
|
|
|
Error *cdpError `json:"error,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cdpEvent represents a CDP event.
|
|
|
|
|
type cdpEvent struct {
|
|
|
|
|
Method string `json:"method"`
|
|
|
|
|
Params map[string]any `json:"params,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cdpError represents a CDP error.
|
|
|
|
|
type cdpError struct {
|
|
|
|
|
Code int `json:"code"`
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
Data string `json:"data,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 16:00:31 +00:00
|
|
|
// TargetInfo represents Chrome DevTools target information.
|
|
|
|
|
type TargetInfo struct {
|
2026-02-19 16:09:11 +00:00
|
|
|
ID string `json:"id"`
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
Title string `json:"title"`
|
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewCDPClient creates a new CDP client connected to the given debug URL.
|
|
|
|
|
// The debug URL should be the Chrome DevTools HTTP endpoint (e.g., http://localhost:9222).
|
|
|
|
|
func NewCDPClient(debugURL string) (*CDPClient, error) {
|
2026-03-23 07:34:16 +00:00
|
|
|
debugBase, err := parseDebugURL(debugURL)
|
2026-02-19 16:09:11 +00:00
|
|
|
if err != nil {
|
2026-03-23 07:34:16 +00:00
|
|
|
return nil, coreerr.E("CDPClient.New", "invalid debug URL", err)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 07:34:16 +00:00
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), debugEndpointTimeout)
|
|
|
|
|
defer cancel()
|
2026-02-19 16:09:11 +00:00
|
|
|
|
2026-03-23 07:34:16 +00:00
|
|
|
targets, err := listTargetsAt(ctx, debugBase)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, coreerr.E("CDPClient.New", "failed to get targets", err)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find a page target
|
|
|
|
|
var wsURL string
|
|
|
|
|
for _, t := range targets {
|
|
|
|
|
if t.Type == "page" && t.WebSocketDebuggerURL != "" {
|
2026-03-23 07:34:16 +00:00
|
|
|
wsURL, err = validateTargetWebSocketURL(debugBase, t.WebSocketDebuggerURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, coreerr.E("CDPClient.New", "invalid target WebSocket URL", err)
|
|
|
|
|
}
|
2026-02-19 16:09:11 +00:00
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if wsURL == "" {
|
2026-03-23 07:34:16 +00:00
|
|
|
newTarget, err := createTargetAt(ctx, debugBase, "")
|
2026-02-19 16:09:11 +00:00
|
|
|
if err != nil {
|
2026-03-16 21:10:49 +00:00
|
|
|
return nil, coreerr.E("CDPClient.New", "no page targets found and failed to create new", err)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 07:34:16 +00:00
|
|
|
wsURL, err = validateTargetWebSocketURL(debugBase, newTarget.WebSocketDebuggerURL)
|
2026-02-19 16:09:11 +00:00
|
|
|
if err != nil {
|
2026-03-23 07:34:16 +00:00
|
|
|
return nil, coreerr.E("CDPClient.New", "invalid new target WebSocket URL", err)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if wsURL == "" {
|
2026-03-16 21:10:49 +00:00
|
|
|
return nil, coreerr.E("CDPClient.New", "no WebSocket URL available", nil)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Connect to WebSocket
|
|
|
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
|
|
|
if err != nil {
|
2026-03-16 21:10:49 +00:00
|
|
|
return nil, coreerr.E("CDPClient.New", "failed to connect to WebSocket", err)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 07:34:16 +00:00
|
|
|
return newCDPClient(debugBase, wsURL, conn), nil
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Close closes the CDP connection.
|
|
|
|
|
func (c *CDPClient) Close() error {
|
2026-03-23 07:34:16 +00:00
|
|
|
c.close(errCDPClientClosed)
|
|
|
|
|
<-c.done
|
|
|
|
|
if c.closeErr != nil {
|
|
|
|
|
return coreerr.E("CDPClient.Close", "failed to close WebSocket", c.closeErr)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Call sends a CDP method call and waits for the response.
|
|
|
|
|
func (c *CDPClient) Call(ctx context.Context, method string, params map[string]any) (map[string]any, error) {
|
|
|
|
|
id := c.msgID.Add(1)
|
|
|
|
|
|
|
|
|
|
msg := cdpMessage{
|
|
|
|
|
ID: id,
|
|
|
|
|
Method: method,
|
2026-03-23 07:34:16 +00:00
|
|
|
Params: cloneMapAny(params),
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Register response channel
|
|
|
|
|
respCh := make(chan *cdpResponse, 1)
|
|
|
|
|
c.pendMu.Lock()
|
|
|
|
|
c.pending[id] = respCh
|
|
|
|
|
c.pendMu.Unlock()
|
|
|
|
|
|
|
|
|
|
defer func() {
|
|
|
|
|
c.pendMu.Lock()
|
|
|
|
|
delete(c.pending, id)
|
|
|
|
|
c.pendMu.Unlock()
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Send message
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
err := c.conn.WriteJSON(msg)
|
|
|
|
|
c.mu.Unlock()
|
|
|
|
|
if err != nil {
|
2026-03-16 21:10:49 +00:00
|
|
|
return nil, coreerr.E("CDPClient.Call", "failed to send message", err)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wait for response
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return nil, ctx.Err()
|
2026-03-23 07:34:16 +00:00
|
|
|
case <-c.ctx.Done():
|
|
|
|
|
return nil, coreerr.E("CDPClient.Call", "client closed", errCDPClientClosed)
|
2026-02-19 16:09:11 +00:00
|
|
|
case resp := <-respCh:
|
|
|
|
|
if resp.Error != nil {
|
2026-03-16 21:10:49 +00:00
|
|
|
return nil, coreerr.E("CDPClient.Call", resp.Error.Message, nil)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
return resp.Result, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// OnEvent registers a handler for CDP events.
|
|
|
|
|
func (c *CDPClient) OnEvent(method string, handler func(map[string]any)) {
|
|
|
|
|
c.handMu.Lock()
|
|
|
|
|
defer c.handMu.Unlock()
|
|
|
|
|
c.handlers[method] = append(c.handlers[method], handler)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// readLoop reads messages from the WebSocket connection.
|
|
|
|
|
func (c *CDPClient) readLoop() {
|
|
|
|
|
defer close(c.done)
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
_, data, err := c.conn.ReadMessage()
|
|
|
|
|
if err != nil {
|
2026-03-23 07:34:16 +00:00
|
|
|
if c.ctx.Err() != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if isTerminalReadError(err) {
|
|
|
|
|
c.close(err)
|
2026-02-19 16:09:11 +00:00
|
|
|
return
|
2026-03-23 07:34:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var netErr net.Error
|
2026-03-26 13:53:43 +00:00
|
|
|
if core.As(err, &netErr) && netErr.Timeout() {
|
2026-02-19 16:09:11 +00:00
|
|
|
continue
|
|
|
|
|
}
|
2026-03-23 07:34:16 +00:00
|
|
|
|
|
|
|
|
c.close(err)
|
|
|
|
|
return
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to parse as response
|
|
|
|
|
var resp cdpResponse
|
2026-03-26 13:53:43 +00:00
|
|
|
if r := core.JSONUnmarshal(data, &resp); r.OK && resp.ID > 0 {
|
2026-02-19 16:09:11 +00:00
|
|
|
c.pendMu.Lock()
|
|
|
|
|
if ch, ok := c.pending[resp.ID]; ok {
|
|
|
|
|
respCopy := resp
|
2026-03-23 07:34:16 +00:00
|
|
|
select {
|
|
|
|
|
case ch <- &respCopy:
|
|
|
|
|
default:
|
|
|
|
|
}
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
c.pendMu.Unlock()
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to parse as event
|
|
|
|
|
var event cdpEvent
|
2026-03-26 13:53:43 +00:00
|
|
|
if r := core.JSONUnmarshal(data, &event); r.OK && event.Method != "" {
|
2026-02-19 16:09:11 +00:00
|
|
|
c.dispatchEvent(event.Method, event.Params)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// dispatchEvent dispatches an event to registered handlers.
|
|
|
|
|
func (c *CDPClient) dispatchEvent(method string, params map[string]any) {
|
|
|
|
|
c.handMu.RLock()
|
2026-02-23 05:26:39 +00:00
|
|
|
handlers := slices.Clone(c.handlers[method])
|
2026-02-19 16:09:11 +00:00
|
|
|
c.handMu.RUnlock()
|
|
|
|
|
|
|
|
|
|
for _, handler := range handlers {
|
|
|
|
|
// Call handler in goroutine to avoid blocking
|
2026-03-23 07:34:16 +00:00
|
|
|
handlerParams := cloneMapAny(params)
|
|
|
|
|
go handler(handlerParams)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Send sends a fire-and-forget CDP message (no response expected).
|
|
|
|
|
func (c *CDPClient) Send(method string, params map[string]any) error {
|
|
|
|
|
msg := cdpMessage{
|
|
|
|
|
Method: method,
|
2026-03-23 07:34:16 +00:00
|
|
|
Params: cloneMapAny(params),
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
return c.conn.WriteJSON(msg)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DebugURL returns the debug HTTP URL.
|
|
|
|
|
func (c *CDPClient) DebugURL() string {
|
|
|
|
|
return c.debugURL
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WebSocketURL returns the WebSocket URL being used.
|
|
|
|
|
func (c *CDPClient) WebSocketURL() string {
|
|
|
|
|
return c.wsURL
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewTab creates a new browser tab and returns a new CDPClient connected to it.
|
|
|
|
|
func (c *CDPClient) NewTab(url string) (*CDPClient, error) {
|
2026-03-23 07:34:16 +00:00
|
|
|
ctx, cancel := context.WithTimeout(c.ctx, debugEndpointTimeout)
|
|
|
|
|
defer cancel()
|
2026-02-19 16:09:11 +00:00
|
|
|
|
2026-03-23 07:34:16 +00:00
|
|
|
target, err := createTargetAt(ctx, c.debugBase, url)
|
2026-02-19 16:09:11 +00:00
|
|
|
if err != nil {
|
2026-03-16 21:10:49 +00:00
|
|
|
return nil, coreerr.E("CDPClient.NewTab", "failed to create new tab", err)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if target.WebSocketDebuggerURL == "" {
|
2026-03-16 21:10:49 +00:00
|
|
|
return nil, coreerr.E("CDPClient.NewTab", "no WebSocket URL for new tab", nil)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 07:34:16 +00:00
|
|
|
wsURL, err := validateTargetWebSocketURL(c.debugBase, target.WebSocketDebuggerURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, coreerr.E("CDPClient.NewTab", "invalid WebSocket URL for new tab", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 16:09:11 +00:00
|
|
|
// Connect to new tab
|
2026-03-23 07:34:16 +00:00
|
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
2026-02-19 16:09:11 +00:00
|
|
|
if err != nil {
|
2026-03-16 21:10:49 +00:00
|
|
|
return nil, coreerr.E("CDPClient.NewTab", "failed to connect to new tab", err)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 07:34:16 +00:00
|
|
|
return newCDPClient(c.debugBase, wsURL, conn), nil
|
|
|
|
|
}
|
2026-02-19 16:09:11 +00:00
|
|
|
|
2026-03-23 07:34:16 +00:00
|
|
|
// CloseTab closes the current tab (target).
|
|
|
|
|
func (c *CDPClient) CloseTab() error {
|
|
|
|
|
targetID, err := targetIDFromWebSocketURL(c.wsURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return coreerr.E("CDPClient.CloseTab", "failed to determine target ID", err)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 07:34:16 +00:00
|
|
|
ctx, cancel := context.WithTimeout(c.ctx, debugEndpointTimeout)
|
|
|
|
|
defer cancel()
|
2026-02-19 16:09:11 +00:00
|
|
|
|
2026-03-23 07:34:16 +00:00
|
|
|
result, err := c.Call(ctx, "Target.closeTarget", map[string]any{
|
|
|
|
|
"targetId": targetID,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return coreerr.E("CDPClient.CloseTab", "failed to close target", err)
|
|
|
|
|
}
|
2026-02-19 16:09:11 +00:00
|
|
|
|
2026-03-23 07:34:16 +00:00
|
|
|
if success, ok := result["success"].(bool); ok && !success {
|
|
|
|
|
return coreerr.E("CDPClient.CloseTab", "target close was not acknowledged", nil)
|
|
|
|
|
}
|
2026-02-19 16:09:11 +00:00
|
|
|
|
2026-03-23 07:34:16 +00:00
|
|
|
return c.Close()
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ListTargets returns all available targets.
|
2026-03-13 16:00:31 +00:00
|
|
|
func ListTargets(debugURL string) ([]TargetInfo, error) {
|
2026-03-23 07:34:16 +00:00
|
|
|
debugBase, err := parseDebugURL(debugURL)
|
2026-02-19 16:09:11 +00:00
|
|
|
if err != nil {
|
2026-03-23 07:34:16 +00:00
|
|
|
return nil, coreerr.E("ListTargets", "invalid debug URL", err)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 07:34:16 +00:00
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), debugEndpointTimeout)
|
|
|
|
|
defer cancel()
|
2026-02-19 16:09:11 +00:00
|
|
|
|
2026-03-23 07:34:16 +00:00
|
|
|
targets, err := listTargetsAt(ctx, debugBase)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, coreerr.E("ListTargets", "failed to get targets", err)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return targets, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 05:26:39 +00:00
|
|
|
// ListTargetsAll returns an iterator over all available targets.
|
2026-03-13 16:00:31 +00:00
|
|
|
func ListTargetsAll(debugURL string) iter.Seq[TargetInfo] {
|
|
|
|
|
return func(yield func(TargetInfo) bool) {
|
2026-02-23 05:26:39 +00:00
|
|
|
targets, err := ListTargets(debugURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
for _, t := range targets {
|
|
|
|
|
if !yield(t) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 16:09:11 +00:00
|
|
|
// GetVersion returns Chrome version information.
|
|
|
|
|
func GetVersion(debugURL string) (map[string]string, error) {
|
2026-03-23 07:34:16 +00:00
|
|
|
debugBase, err := parseDebugURL(debugURL)
|
2026-02-19 16:09:11 +00:00
|
|
|
if err != nil {
|
2026-03-23 07:34:16 +00:00
|
|
|
return nil, coreerr.E("GetVersion", "invalid debug URL", err)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 07:34:16 +00:00
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), debugEndpointTimeout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
body, err := doDebugRequest(ctx, debugBase, "/json/version", "")
|
2026-02-19 16:09:11 +00:00
|
|
|
if err != nil {
|
2026-03-23 07:34:16 +00:00
|
|
|
return nil, coreerr.E("GetVersion", "failed to get version", err)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var version map[string]string
|
2026-03-26 13:53:43 +00:00
|
|
|
if r := core.JSONUnmarshal(body, &version); !r.OK {
|
|
|
|
|
return nil, coreerr.E("GetVersion", "failed to parse version", nil)
|
2026-02-19 16:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return version, nil
|
|
|
|
|
}
|
2026-03-23 07:34:16 +00:00
|
|
|
|
|
|
|
|
func newCDPClient(debugBase *url.URL, wsURL string, conn *websocket.Conn) *CDPClient {
|
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
|
baseCopy := *debugBase
|
|
|
|
|
|
|
|
|
|
client := &CDPClient{
|
|
|
|
|
conn: conn,
|
|
|
|
|
debugURL: canonicalDebugURL(&baseCopy),
|
|
|
|
|
debugBase: &baseCopy,
|
|
|
|
|
wsURL: wsURL,
|
|
|
|
|
pending: make(map[int64]chan *cdpResponse),
|
|
|
|
|
handlers: make(map[string][]func(map[string]any)),
|
|
|
|
|
ctx: ctx,
|
|
|
|
|
cancel: cancel,
|
|
|
|
|
done: make(chan struct{}),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
go client.readLoop()
|
|
|
|
|
|
|
|
|
|
return client
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseDebugURL(raw string) (*url.URL, error) {
|
|
|
|
|
debugURL, err := url.Parse(raw)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if debugURL.Scheme != "http" && debugURL.Scheme != "https" {
|
|
|
|
|
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL must use http or https", nil)
|
|
|
|
|
}
|
|
|
|
|
if debugURL.Host == "" {
|
|
|
|
|
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL host is required", nil)
|
|
|
|
|
}
|
|
|
|
|
if debugURL.User != nil {
|
|
|
|
|
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL must not include credentials", nil)
|
|
|
|
|
}
|
|
|
|
|
if debugURL.RawQuery != "" || debugURL.Fragment != "" {
|
|
|
|
|
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL must not include query or fragment", nil)
|
|
|
|
|
}
|
|
|
|
|
if debugURL.Path == "" {
|
|
|
|
|
debugURL.Path = "/"
|
|
|
|
|
}
|
|
|
|
|
if debugURL.Path != "/" {
|
|
|
|
|
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL must point at the DevTools root", nil)
|
|
|
|
|
}
|
|
|
|
|
return debugURL, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func canonicalDebugURL(debugURL *url.URL) string {
|
2026-03-26 13:53:43 +00:00
|
|
|
return core.TrimSuffix(debugURL.String(), "/")
|
2026-03-23 07:34:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func doDebugRequest(ctx context.Context, debugBase *url.URL, endpoint, rawQuery string) ([]byte, error) {
|
|
|
|
|
reqURL := *debugBase
|
|
|
|
|
reqURL.Path = endpoint
|
|
|
|
|
reqURL.RawPath = ""
|
|
|
|
|
reqURL.RawQuery = rawQuery
|
|
|
|
|
reqURL.Fragment = ""
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := defaultDebugHTTPClient.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|
|
|
|
return nil, coreerr.E("CDPClient.doDebugRequest", "debug endpoint returned "+resp.Status, nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return body, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func listTargetsAt(ctx context.Context, debugBase *url.URL) ([]TargetInfo, error) {
|
|
|
|
|
body, err := doDebugRequest(ctx, debugBase, "/json", "")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var targets []TargetInfo
|
2026-03-26 13:53:43 +00:00
|
|
|
if r := core.JSONUnmarshal(body, &targets); !r.OK {
|
|
|
|
|
return nil, coreerr.E("CDPClient.listTargetsAt", "failed to parse targets", nil)
|
2026-03-23 07:34:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return targets, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func createTargetAt(ctx context.Context, debugBase *url.URL, pageURL string) (*TargetInfo, error) {
|
|
|
|
|
rawQuery := ""
|
|
|
|
|
if pageURL != "" {
|
|
|
|
|
rawQuery = url.QueryEscape(pageURL)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body, err := doDebugRequest(ctx, debugBase, "/json/new", rawQuery)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var target TargetInfo
|
2026-03-26 13:53:43 +00:00
|
|
|
if r := core.JSONUnmarshal(body, &target); !r.OK {
|
|
|
|
|
return nil, coreerr.E("CDPClient.createTargetAt", "failed to parse target", nil)
|
2026-03-23 07:34:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &target, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validateTargetWebSocketURL(debugBase *url.URL, raw string) (string, error) {
|
|
|
|
|
wsURL, err := url.Parse(raw)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
if wsURL.Scheme != "ws" && wsURL.Scheme != "wss" {
|
|
|
|
|
return "", coreerr.E("CDPClient.validateTargetWebSocketURL", "target WebSocket URL must use ws or wss", nil)
|
|
|
|
|
}
|
|
|
|
|
if !sameEndpointHost(debugBase, wsURL) {
|
|
|
|
|
return "", coreerr.E("CDPClient.validateTargetWebSocketURL", "target WebSocket URL must match debug URL host", nil)
|
|
|
|
|
}
|
|
|
|
|
return wsURL.String(), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sameEndpointHost(httpURL, wsURL *url.URL) bool {
|
|
|
|
|
return strings.EqualFold(httpURL.Hostname(), wsURL.Hostname()) && normalisedPort(httpURL) == normalisedPort(wsURL)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func normalisedPort(u *url.URL) string {
|
|
|
|
|
if port := u.Port(); port != "" {
|
|
|
|
|
return port
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch u.Scheme {
|
|
|
|
|
case "http", "ws":
|
|
|
|
|
return "80"
|
|
|
|
|
case "https", "wss":
|
|
|
|
|
return "443"
|
|
|
|
|
default:
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func targetIDFromWebSocketURL(raw string) (string, error) {
|
|
|
|
|
wsURL, err := url.Parse(raw)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 13:53:43 +00:00
|
|
|
targetID := path.Base(core.TrimSuffix(wsURL.Path, "/"))
|
2026-03-23 07:34:16 +00:00
|
|
|
if targetID == "." || targetID == "/" || targetID == "" {
|
|
|
|
|
return "", coreerr.E("CDPClient.targetIDFromWebSocketURL", "missing target ID in WebSocket URL", nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return targetID, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *CDPClient) close(reason error) {
|
|
|
|
|
c.closeOnce.Do(func() {
|
|
|
|
|
c.cancel()
|
|
|
|
|
c.failPending(reason)
|
|
|
|
|
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
err := c.conn.Close()
|
|
|
|
|
c.mu.Unlock()
|
|
|
|
|
if err != nil && !isTerminalReadError(err) {
|
|
|
|
|
c.closeErr = err
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *CDPClient) failPending(err error) {
|
|
|
|
|
c.pendMu.Lock()
|
|
|
|
|
defer c.pendMu.Unlock()
|
|
|
|
|
|
|
|
|
|
for id, ch := range c.pending {
|
|
|
|
|
resp := &cdpResponse{
|
|
|
|
|
ID: id,
|
|
|
|
|
Error: &cdpError{
|
|
|
|
|
Message: err.Error(),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
select {
|
|
|
|
|
case ch <- resp:
|
|
|
|
|
default:
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isTerminalReadError(err error) bool {
|
|
|
|
|
if err == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-03-26 13:53:43 +00:00
|
|
|
if core.Is(err, net.ErrClosed) || core.Is(err, websocket.ErrCloseSent) {
|
2026-03-23 07:34:16 +00:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
var closeErr *websocket.CloseError
|
2026-03-26 13:53:43 +00:00
|
|
|
return core.As(err, &closeErr)
|
2026-03-23 07:34:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func cloneMapAny(src map[string]any) map[string]any {
|
|
|
|
|
if src == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dst := make(map[string]any, len(src))
|
|
|
|
|
for key, value := range src {
|
|
|
|
|
dst[key] = cloneAny(value)
|
|
|
|
|
}
|
|
|
|
|
return dst
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func cloneSliceAny(src []any) []any {
|
|
|
|
|
if src == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dst := make([]any, len(src))
|
|
|
|
|
for i, value := range src {
|
|
|
|
|
dst[i] = cloneAny(value)
|
|
|
|
|
}
|
|
|
|
|
return dst
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func cloneAny(value any) any {
|
|
|
|
|
switch typed := value.(type) {
|
|
|
|
|
case map[string]any:
|
|
|
|
|
return cloneMapAny(typed)
|
|
|
|
|
case []any:
|
|
|
|
|
return cloneSliceAny(typed)
|
|
|
|
|
default:
|
|
|
|
|
return typed
|
|
|
|
|
}
|
|
|
|
|
}
|