mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 14:13:21 +00:00
478 lines
15 KiB
Go
478 lines
15 KiB
Go
package browser
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-rod/rod"
|
|
"github.com/go-rod/rod/lib/launcher"
|
|
)
|
|
|
|
// Backend identifies the CDP browser goclaw is talking to.
|
|
// Chrome multiplexes all tabs/contexts over a single WS; Lightpanda requires
|
|
// one CDP connection per tab (each connection is its own browser).
|
|
type Backend string
|
|
|
|
const (
|
|
BackendChrome Backend = "chrome"
|
|
BackendLightpanda Backend = "lightpanda"
|
|
)
|
|
|
|
// Manager handles the browser lifecycle and page management.
|
|
//
|
|
// Two backends are supported:
|
|
// - Chrome (default): one shared *rod.Browser, tabs are pages within it,
|
|
// tenants are isolated via Incognito browser contexts.
|
|
// - Lightpanda: no shared browser. Each tab mints its own CDP connection
|
|
// (tracked in pageConns). Tenant isolation is implicit — every connection
|
|
// is a fresh browser server-side.
|
|
type Manager struct {
|
|
mu sync.Mutex
|
|
browser *rod.Browser // chrome only; nil on lightpanda
|
|
launcher *launcher.Launcher // retained for PID-based cleanup on crash
|
|
refs *RefStore
|
|
pages map[string]*rod.Page // targetID → page
|
|
pageConns map[string]*rod.Browser // lightpanda only: targetID → dedicated CDP conn
|
|
pageInfos map[string]TabInfo // lightpanda only: cached URL/Title (page.Info() is unreliable upstream)
|
|
console map[string][]ConsoleMessage // targetID → console messages
|
|
tenantCtxs map[string]*rod.Browser // chrome only: browser scope key → incognito browser context
|
|
pageTenants map[string]string // targetID → browser scope key (for filtering)
|
|
pageLastUsed map[string]time.Time // targetID → last access time
|
|
backend Backend // "chrome" or "lightpanda"; auto-detected in Start() if empty
|
|
cdpURL string // resolved CDP WS URL for remote sidecar; used to mint new conns on lightpanda
|
|
headless bool
|
|
remoteURL string // CDP endpoint for remote Chrome (sidecar); skips local launcher
|
|
actionTimeout time.Duration // per-action context timeout (default 30s)
|
|
idleTimeout time.Duration // auto-close pages idle longer than this (default 10m, 0=disabled)
|
|
maxPages int // max open pages per tenant (default 5)
|
|
cookieProvider CookieProvider
|
|
stopReaper chan struct{} // signal to stop the reaper goroutine
|
|
logger *slog.Logger
|
|
nextLpTabSeq uint64 // lightpanda only: monotonic counter for synthetic tab IDs
|
|
}
|
|
|
|
// Option configures a Manager.
|
|
type Option func(*Manager)
|
|
|
|
// WithHeadless sets headless mode (default false).
|
|
func WithHeadless(h bool) Option {
|
|
return func(m *Manager) { m.headless = h }
|
|
}
|
|
|
|
// WithRemoteURL sets a remote CDP endpoint (e.g. "ws://chrome:9222").
|
|
// When set, Start() connects to the remote sidecar instead of launching locally.
|
|
func WithRemoteURL(url string) Option {
|
|
return func(m *Manager) { m.remoteURL = url }
|
|
}
|
|
|
|
// WithBackend sets the browser backend explicitly ("chrome" or "lightpanda").
|
|
// If unset and RemoteURL is configured, Start() probes /json/version and
|
|
// auto-detects. Local (non-remote) launches are always Chrome.
|
|
func WithBackend(b Backend) Option {
|
|
return func(m *Manager) { m.backend = b }
|
|
}
|
|
|
|
// WithLogger sets a custom logger.
|
|
func WithLogger(l *slog.Logger) Option {
|
|
return func(m *Manager) { m.logger = l }
|
|
}
|
|
|
|
// WithActionTimeout sets the per-action context timeout.
|
|
func WithActionTimeout(d time.Duration) Option {
|
|
return func(m *Manager) { m.actionTimeout = d }
|
|
}
|
|
|
|
// WithIdleTimeout sets the idle page auto-close timeout. 0 disables the reaper.
|
|
func WithIdleTimeout(d time.Duration) Option {
|
|
return func(m *Manager) { m.idleTimeout = d }
|
|
}
|
|
|
|
// WithMaxPages sets the max open pages per tenant.
|
|
func WithMaxPages(n int) Option {
|
|
return func(m *Manager) { m.maxPages = n }
|
|
}
|
|
|
|
// WithCookieProvider sets the provider for selected cookie sync into new pages.
|
|
func WithCookieProvider(p CookieProvider) Option {
|
|
return func(m *Manager) { m.cookieProvider = p }
|
|
}
|
|
|
|
// New creates a Manager with options.
|
|
func New(opts ...Option) *Manager {
|
|
m := &Manager{
|
|
refs: NewRefStore(),
|
|
pages: make(map[string]*rod.Page),
|
|
pageConns: make(map[string]*rod.Browser),
|
|
pageInfos: make(map[string]TabInfo),
|
|
console: make(map[string][]ConsoleMessage),
|
|
tenantCtxs: make(map[string]*rod.Browser),
|
|
pageTenants: make(map[string]string),
|
|
pageLastUsed: make(map[string]time.Time),
|
|
actionTimeout: 30 * time.Second,
|
|
idleTimeout: 10 * time.Minute,
|
|
maxPages: 5,
|
|
logger: slog.Default(),
|
|
}
|
|
for _, o := range opts {
|
|
o(m)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// ActionTimeout returns the configured per-action timeout.
|
|
func (m *Manager) ActionTimeout() time.Duration {
|
|
return m.actionTimeout
|
|
}
|
|
|
|
// SetCookieProvider updates the selected-cookie provider after store setup.
|
|
func (m *Manager) SetCookieProvider(p CookieProvider) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.cookieProvider = p
|
|
}
|
|
|
|
// Backend returns the current backend (resolved after Start()).
|
|
func (m *Manager) Backend() Backend {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.backend
|
|
}
|
|
|
|
// isRunningLocked reports whether the manager has an active backend connection.
|
|
// Must be called with mu held.
|
|
func (m *Manager) isRunningLocked() bool {
|
|
if m.backend == BackendLightpanda {
|
|
return m.cdpURL != ""
|
|
}
|
|
return m.browser != nil
|
|
}
|
|
|
|
// resetPageMapsLocked clears all page-related tracking. Must be called with mu held.
|
|
func (m *Manager) resetPageMapsLocked() {
|
|
m.pages = make(map[string]*rod.Page)
|
|
m.pageConns = make(map[string]*rod.Browser)
|
|
m.pageInfos = make(map[string]TabInfo)
|
|
m.console = make(map[string][]ConsoleMessage)
|
|
m.pageTenants = make(map[string]string)
|
|
m.pageLastUsed = make(map[string]time.Time)
|
|
}
|
|
|
|
// probeBackendLocked queries /json/version to detect whether the remote is
|
|
// Lightpanda or Chrome. Must be called with mu held. Falls back to Chrome on
|
|
// any error or ambiguity — Chrome is the safe default.
|
|
func (m *Manager) probeBackendLocked() Backend {
|
|
if m.remoteURL == "" {
|
|
return BackendChrome // local launcher is Chrome
|
|
}
|
|
parsed, err := url.Parse(m.remoteURL)
|
|
if err != nil {
|
|
return BackendChrome
|
|
}
|
|
host := parsed.Hostname()
|
|
port := parsed.Port()
|
|
if port == "" {
|
|
port = "9222"
|
|
}
|
|
resp, err := cdpHTTPClient.Get(fmt.Sprintf("http://%s:%s/json/version", host, port)) //nolint:gosec // user-configured remote URL
|
|
if err != nil {
|
|
return BackendChrome
|
|
}
|
|
defer resp.Body.Close()
|
|
var ver struct {
|
|
Browser string `json:"Browser"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&ver); err != nil {
|
|
return BackendChrome
|
|
}
|
|
if strings.Contains(strings.ToLower(ver.Browser), "lightpanda") {
|
|
return BackendLightpanda
|
|
}
|
|
return BackendChrome
|
|
}
|
|
|
|
// touchPageLocked updates the last-used timestamp for a page. Must be called with mu held.
|
|
func (m *Manager) touchPageLocked(targetID string) {
|
|
m.pageLastUsed[targetID] = time.Now()
|
|
}
|
|
|
|
// Start launches a local Chrome browser or connects to a remote one.
|
|
// If already connected but the connection is dead, it reconnects automatically.
|
|
//
|
|
// On Lightpanda there is no persistent shared browser — Start() only resolves
|
|
// the CDP URL and (optionally) auto-detects the backend. Each tab will mint
|
|
// its own CDP connection when opened.
|
|
func (m *Manager) Start(ctx context.Context) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Lightpanda path: no persistent browser. Resolve CDP URL once, start reaper.
|
|
if m.backend == BackendLightpanda {
|
|
if m.cdpURL == "" {
|
|
u, err := resolveRemoteCDP(m.remoteURL)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve remote CDP at %s: %w", m.remoteURL, err)
|
|
}
|
|
m.cdpURL = u
|
|
m.logger.Info("lightpanda backend ready", "cdp", m.cdpURL)
|
|
}
|
|
if m.idleTimeout > 0 && m.stopReaper == nil {
|
|
m.stopReaper = make(chan struct{})
|
|
go m.runReaper()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// If browser exists, check if connection is still alive
|
|
if m.browser != nil {
|
|
if _, err := m.browser.Pages(); err == nil {
|
|
return nil // already connected and healthy
|
|
}
|
|
// Connection dead — clean up and reconnect
|
|
m.logger.Info("browser connection lost, reconnecting")
|
|
m.cleanupDeadBrowserLocked()
|
|
}
|
|
|
|
var controlURL string
|
|
|
|
if m.remoteURL != "" {
|
|
// Remote sidecar — query /json/version and fix host for Docker networking
|
|
u, err := resolveRemoteCDP(m.remoteURL)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve remote CDP at %s: %w", m.remoteURL, err)
|
|
}
|
|
controlURL = u
|
|
m.cdpURL = u
|
|
|
|
// Auto-detect backend if caller didn't set one explicitly.
|
|
if m.backend == "" {
|
|
m.backend = m.probeBackendLocked()
|
|
m.logger.Info("auto-detected browser backend", "backend", m.backend)
|
|
if m.backend == BackendLightpanda {
|
|
// Switch to lightpanda-style lifecycle: no persistent browser.
|
|
if m.idleTimeout > 0 && m.stopReaper == nil {
|
|
m.stopReaper = make(chan struct{})
|
|
go m.runReaper()
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
m.logger.Info("connecting to remote Chrome", "cdp", controlURL, "remote", m.remoteURL)
|
|
} else {
|
|
// Local Chrome — launch via rod launcher with stability flags
|
|
launchCtx, launchCancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer launchCancel()
|
|
|
|
l := launcher.New().
|
|
Context(launchCtx).
|
|
Leakless(true).
|
|
Headless(m.headless).
|
|
Set("disable-gpu").
|
|
Set("no-first-run").
|
|
Set("no-default-browser-check").
|
|
Set("disable-dev-shm-usage").
|
|
Set("disable-software-rasterizer").
|
|
Set("disable-extensions").
|
|
Set("disable-background-networking").
|
|
Set("disable-renderer-backgrounding").
|
|
Set("disable-background-timer-throttling").
|
|
Set("disable-backgrounding-occluded-windows")
|
|
|
|
u, err := l.Launch()
|
|
if err != nil {
|
|
return fmt.Errorf("launch Chrome: %w", err)
|
|
}
|
|
controlURL = u
|
|
m.launcher = l
|
|
m.backend = BackendChrome // local launcher is always Chrome
|
|
m.logger.Info("Chrome launched", "cdp", controlURL, "headless", m.headless, "pid", l.PID())
|
|
}
|
|
|
|
// Connect using a long-lived background context, the same way reconnectLocked
|
|
// does. Binding m.browser to a timeout/request context here would cancel it
|
|
// the moment Start returns (via defer), leaving the shared browser handle with
|
|
// a dead context. Later operations that reuse it — notably m.browser.Incognito()
|
|
// in tenantBrowserLocked — then fail immediately with "context canceled", which
|
|
// is why a successful "start" is followed by a failing "open"/tab creation.
|
|
// resolveRemoteCDP already bounds remote reachability, and the local launcher
|
|
// path is bounded by launchCtx above.
|
|
b := rod.New().ControlURL(controlURL)
|
|
if err := b.Connect(); err != nil {
|
|
// If local launch succeeded but connect failed, kill the orphan process
|
|
if m.launcher != nil {
|
|
m.launcher.Kill()
|
|
m.launcher.Cleanup()
|
|
m.launcher = nil
|
|
}
|
|
return fmt.Errorf("connect to Chrome: %w", err)
|
|
}
|
|
|
|
m.browser = b
|
|
|
|
// Start idle-page reaper if configured
|
|
if m.idleTimeout > 0 && m.stopReaper == nil {
|
|
m.stopReaper = make(chan struct{})
|
|
go m.runReaper()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop closes the browser (local) or disconnects (remote sidecar).
|
|
// On Lightpanda, closes every per-tab CDP connection (which tears down each
|
|
// browser server-side).
|
|
func (m *Manager) Stop(ctx context.Context) error {
|
|
// Grab and nil-out stopReaper under the lock, then close outside to avoid
|
|
// deadlock (reaper goroutine also acquires mu).
|
|
m.mu.Lock()
|
|
ch := m.stopReaper
|
|
m.stopReaper = nil
|
|
m.mu.Unlock()
|
|
if ch != nil {
|
|
close(ch)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if !m.isRunningLocked() {
|
|
return nil
|
|
}
|
|
|
|
// Lightpanda: close every per-tab connection. Lightpanda auto-cleans the
|
|
// browser on disconnect, so no page.Close() is required. rod.Browser.Close
|
|
// calls Browser.close which Lightpanda doesn't implement (UnknownMethod);
|
|
// the WS drops regardless, so swallow the error.
|
|
if m.backend == BackendLightpanda {
|
|
for _, conn := range m.pageConns {
|
|
_ = conn.Close()
|
|
}
|
|
m.cdpURL = ""
|
|
m.resetPageMapsLocked()
|
|
return nil
|
|
}
|
|
|
|
m.closeTenantContextsLocked()
|
|
|
|
var err error
|
|
if m.remoteURL == "" {
|
|
// Local Chrome — close the browser process
|
|
err = m.browser.Close()
|
|
// Force-kill via launcher if retained
|
|
if m.launcher != nil {
|
|
m.launcher.Kill()
|
|
m.launcher.Cleanup()
|
|
m.launcher = nil
|
|
}
|
|
}
|
|
// Remote Chrome — just drop the connection; sidecar stays alive
|
|
|
|
m.browser = nil
|
|
m.resetPageMapsLocked()
|
|
return err
|
|
}
|
|
|
|
// closeTenantContextsLocked closes all incognito browser contexts. Must be called with mu held.
|
|
func (m *Manager) closeTenantContextsLocked() {
|
|
for tid, ctx := range m.tenantCtxs {
|
|
if err := ctx.Close(); err != nil {
|
|
m.logger.Warn("failed to close tenant browser context", "tenant", tid, "error", err)
|
|
}
|
|
}
|
|
m.tenantCtxs = make(map[string]*rod.Browser)
|
|
}
|
|
|
|
// cleanupDeadBrowserLocked resets all state and kills any orphan Chrome process.
|
|
// Must be called with mu held.
|
|
func (m *Manager) cleanupDeadBrowserLocked() {
|
|
m.closeTenantContextsLocked()
|
|
if m.launcher != nil {
|
|
m.launcher.Kill()
|
|
m.launcher.Cleanup()
|
|
m.launcher = nil
|
|
}
|
|
// Lightpanda: also drop any dedicated page conns (will be torn down when
|
|
// the underlying WS dies anyway, but keep state consistent).
|
|
for _, conn := range m.pageConns {
|
|
_ = conn.Close()
|
|
}
|
|
m.browser = nil
|
|
m.resetPageMapsLocked()
|
|
m.refs = NewRefStore()
|
|
}
|
|
|
|
// MasterTenantID is the well-known master tenant UUID string.
|
|
// Pages opened without a tenant context or by the master tenant use the main browser directly.
|
|
const MasterTenantID = "0193a5b0-7000-7000-8000-000000000001"
|
|
|
|
// tenantBrowserLocked returns an isolated incognito browser context for the given scope.
|
|
// Legacy master/empty scopes with no user/agent use the main browser.
|
|
// Must be called with mu held.
|
|
func (m *Manager) tenantBrowserLocked(scopeKey string) (*rod.Browser, error) {
|
|
if m.browser == nil {
|
|
return nil, fmt.Errorf("browser not running")
|
|
}
|
|
if scopeKey == "" || scopeKey == MasterTenantID {
|
|
return m.browser, nil
|
|
}
|
|
// Return existing incognito context
|
|
if ctx, ok := m.tenantCtxs[scopeKey]; ok {
|
|
return ctx, nil
|
|
}
|
|
// Create new incognito context for this scope.
|
|
incognito, err := m.browser.Incognito()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create incognito context for browser scope %s: %w", scopeKey, err)
|
|
}
|
|
m.tenantCtxs[scopeKey] = incognito
|
|
m.logger.Info("created incognito browser context", "scope", scopeKey)
|
|
return incognito, nil
|
|
}
|
|
|
|
// Status returns current browser status.
|
|
func (m *Manager) Status() *StatusInfo {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
info := &StatusInfo{
|
|
Running: m.isRunningLocked(),
|
|
Headless: m.headless,
|
|
RemoteURL: m.remoteURL,
|
|
ActionTimeoutMs: int(m.actionTimeout / time.Millisecond),
|
|
IdleTimeoutMs: int(m.idleTimeout / time.Millisecond),
|
|
MaxPages: m.maxPages,
|
|
IsolationMode: "tenant_user_agent",
|
|
CookieSync: m.cookieProvider != nil,
|
|
}
|
|
|
|
if !m.isRunningLocked() {
|
|
return info
|
|
}
|
|
|
|
// Lightpanda: no upstream /json/list, report from the local map.
|
|
if m.backend == BackendLightpanda {
|
|
info.Tabs = len(m.pages)
|
|
for _, p := range m.pages {
|
|
if pi, err := p.Info(); err == nil && pi != nil {
|
|
info.URL = pi.URL
|
|
break
|
|
}
|
|
}
|
|
return info
|
|
}
|
|
|
|
pages, _ := m.browser.Pages()
|
|
info.Tabs = len(pages)
|
|
if len(pages) > 0 {
|
|
if pageInfo, err := pages[0].Info(); err == nil {
|
|
info.URL = pageInfo.URL
|
|
}
|
|
}
|
|
return info
|
|
}
|