mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 06:13:57 +00:00
Live testing surfaced three issues:
- Lightpanda numbers targets per-browser, and each conn is its own
browser, so every tab gets the same upstream targetID
("FID-0000000001"). Synthesize globally-unique "lp-N" keys for our
internal map so multi-tenant tab tracking works.
- page.Info() returns valid data once post-open then errors on
subsequent calls, which made ListTabs silently drop tabs. Cache URL
and Title at OpenTab time and read from the cache in ListTabs.
- rod.Browser.Close() calls Browser.close which Lightpanda doesn't
implement; the WS drops cleanly anyway. Swallow the error to quiet
the noisy log line.
Two Lightpanda upstream bugs are documented in docs/browser-backends.md
and exercised by TestLightpanda_KnownUpstreamGaps:
1. Accessibility.getFullAXTree returns nodeId as a JSON number
(CDP spec: string)
2. Runtime.evaluate rejects go-rod's function-apply wrapper
374 lines
10 KiB
Go
374 lines
10 KiB
Go
package browser
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/go-rod/rod"
|
|
"github.com/go-rod/rod/lib/proto"
|
|
)
|
|
|
|
// ListTabs returns open tabs filtered by the caller's tenant context.
|
|
func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if !m.isRunningLocked() {
|
|
return nil, fmt.Errorf("browser not running")
|
|
}
|
|
|
|
tenantID := tenantIDFromCtx(ctx)
|
|
|
|
// Lightpanda: no server-side enumeration — return what we're tracking.
|
|
// Use the cache populated at OpenTab; page.Info() is unreliable upstream
|
|
// after the initial call.
|
|
if m.backend == BackendLightpanda {
|
|
tabs := make([]TabInfo, 0, len(m.pages))
|
|
for tid := range m.pages {
|
|
if !m.pageVisibleToTenantLocked(tid, tenantID) {
|
|
continue
|
|
}
|
|
if cached, ok := m.pageInfos[tid]; ok {
|
|
tabs = append(tabs, cached)
|
|
continue
|
|
}
|
|
tabs = append(tabs, TabInfo{TargetID: tid})
|
|
}
|
|
return tabs, nil
|
|
}
|
|
|
|
// Use tenant-scoped browser context for page listing
|
|
b, err := m.tenantBrowserLocked(tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pages, err := b.Pages()
|
|
if err != nil {
|
|
if m.remoteURL != "" {
|
|
if reconnErr := m.reconnectLocked(); reconnErr != nil {
|
|
return nil, fmt.Errorf("list pages: %w (reconnect also failed: %v)", err, reconnErr)
|
|
}
|
|
m.logger.Info("auto-reconnected to remote Chrome")
|
|
// Re-acquire tenant browser after reconnect (incognito contexts were reset)
|
|
b, err = m.tenantBrowserLocked(tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pages, err = b.Pages()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list pages after reconnect: %w", err)
|
|
}
|
|
} else {
|
|
return nil, fmt.Errorf("list pages: %w", err)
|
|
}
|
|
}
|
|
|
|
tabs := make([]TabInfo, 0, len(pages))
|
|
for _, p := range pages {
|
|
info, err := p.Info()
|
|
if err != nil || info == nil {
|
|
continue
|
|
}
|
|
tid := string(p.TargetID)
|
|
m.pages[tid] = p
|
|
if tenantID != "" {
|
|
m.pageTenants[tid] = tenantID
|
|
}
|
|
tabs = append(tabs, TabInfo{
|
|
TargetID: tid,
|
|
URL: info.URL,
|
|
Title: info.Title,
|
|
})
|
|
}
|
|
return tabs, nil
|
|
}
|
|
|
|
// pageVisibleToTenantLocked reports whether a page is accessible to the given tenant.
|
|
// Master tenant and empty context see everything; scoped tenants see only their own pages.
|
|
// Must be called with mu held.
|
|
func (m *Manager) pageVisibleToTenantLocked(targetID, tenantID string) bool {
|
|
if tenantID == "" || tenantID == MasterTenantID {
|
|
return true
|
|
}
|
|
owner, ok := m.pageTenants[targetID]
|
|
if !ok {
|
|
return false // scoped tenant can't see pages with no recorded owner
|
|
}
|
|
return owner == tenantID
|
|
}
|
|
|
|
// OpenTab opens a new tab with the given URL.
|
|
// Pages are created within the tenant's incognito browser context for isolation.
|
|
// If the tenant already has maxPages open, the oldest idle page is closed first.
|
|
//
|
|
// Lightpanda: each tab gets its own dedicated CDP connection (lightpanda
|
|
// requires 1 conn per tab, and each connection is its own browser, so tenant
|
|
// isolation is automatic).
|
|
func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) {
|
|
scope := scopeFromCtx(ctx)
|
|
cookies, err := m.cookiesForURL(ctx, scope, url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load browser cookies: %w", err)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
tenantID := scope.Key()
|
|
|
|
// Enforce max pages per tenant
|
|
if m.maxPages > 0 {
|
|
m.evictOldestIfOverLimitLocked(tenantID)
|
|
}
|
|
|
|
if m.backend == BackendLightpanda {
|
|
return m.openTabLightpandaLocked(ctx, tenantID, url)
|
|
}
|
|
|
|
b, err := m.tenantBrowserLocked(tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
initialURL := url
|
|
if len(cookies) > 0 {
|
|
initialURL = "about:blank"
|
|
}
|
|
page, err := b.Page(proto.TargetCreateTarget{URL: initialURL})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open tab: %w", err)
|
|
}
|
|
if len(cookies) > 0 {
|
|
if err := page.SetCookies(cookies); err != nil {
|
|
_ = page.Close()
|
|
return nil, fmt.Errorf("set browser cookies: %w", err)
|
|
}
|
|
if err := page.Navigate(url); err != nil {
|
|
_ = page.Close()
|
|
return nil, fmt.Errorf("navigate after setting cookies: %w", err)
|
|
}
|
|
}
|
|
|
|
// Watchdog: close page on ctx cancel to unblock WaitStable CDP call.
|
|
stopWatchdog := watchPageClose(ctx, page)
|
|
if err := page.WaitStable(300 * time.Millisecond); err != nil {
|
|
stopWatchdog()
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
return nil, fmt.Errorf("wait stable: %w", err)
|
|
}
|
|
stopWatchdog()
|
|
info, _ := page.Info()
|
|
tid := string(page.TargetID)
|
|
m.pages[tid] = page
|
|
m.touchPageLocked(tid)
|
|
if tenantID != "" {
|
|
m.pageTenants[tid] = tenantID
|
|
}
|
|
|
|
// Set up console listener
|
|
m.setupConsoleListener(page, tid)
|
|
|
|
tab := &TabInfo{TargetID: tid, URL: url}
|
|
if info != nil {
|
|
tab.URL = info.URL
|
|
tab.Title = info.Title
|
|
}
|
|
return tab, nil
|
|
}
|
|
|
|
// openTabLightpandaLocked mints a fresh CDP connection, creates a browser
|
|
// context (required per-conn on lightpanda), creates a target, and attaches
|
|
// the resulting rod.Page. Must be called with mu held.
|
|
func (m *Manager) openTabLightpandaLocked(ctx context.Context, tenantID, targetURL string) (*TabInfo, error) {
|
|
conn := rod.New().Context(ctx).ControlURL(m.cdpURL)
|
|
if err := conn.Connect(); err != nil {
|
|
return nil, fmt.Errorf("lightpanda: connect: %w", err)
|
|
}
|
|
|
|
// Lightpanda requires Target.createBrowserContext per connection.
|
|
bc, err := proto.TargetCreateBrowserContext{}.Call(conn)
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
return nil, fmt.Errorf("lightpanda: create browser context: %w", err)
|
|
}
|
|
|
|
tgt, err := proto.TargetCreateTarget{
|
|
URL: targetURL,
|
|
BrowserContextID: bc.BrowserContextID,
|
|
}.Call(conn)
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
return nil, fmt.Errorf("lightpanda: create target: %w", err)
|
|
}
|
|
|
|
page, err := conn.PageFromTarget(tgt.TargetID)
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
return nil, fmt.Errorf("lightpanda: page from target: %w", err)
|
|
}
|
|
|
|
// Best-effort stability wait — lightpanda may not fire every lifecycle event.
|
|
stopWatchdog := watchPageClose(ctx, page)
|
|
_ = page.WaitStable(300 * time.Millisecond)
|
|
stopWatchdog()
|
|
|
|
info, _ := page.Info()
|
|
// Lightpanda numbers targets per-browser, and each conn is its own browser,
|
|
// so every conn's first target is "FID-0000000001". Synthesize a globally
|
|
// unique key for our maps; the upstream targetID is only needed inside this
|
|
// function (for createTarget / PageFromTarget).
|
|
m.nextLpTabSeq++
|
|
tid := fmt.Sprintf("lp-%d", m.nextLpTabSeq)
|
|
m.pages[tid] = page
|
|
m.pageConns[tid] = conn
|
|
m.touchPageLocked(tid)
|
|
if tenantID != "" {
|
|
m.pageTenants[tid] = tenantID
|
|
}
|
|
m.setupConsoleListener(page, tid)
|
|
|
|
tab := TabInfo{TargetID: tid, URL: targetURL}
|
|
if info != nil {
|
|
tab.URL = info.URL
|
|
tab.Title = info.Title
|
|
}
|
|
// Cache for ListTabs — page.Info() is unreliable on Lightpanda after the
|
|
// initial post-open call.
|
|
m.pageInfos[tid] = tab
|
|
tabCopy := tab
|
|
return &tabCopy, nil
|
|
}
|
|
|
|
// evictOldestIfOverLimitLocked closes the oldest idle page for a tenant if at or over maxPages.
|
|
// Must be called with mu held.
|
|
func (m *Manager) evictOldestIfOverLimitLocked(tenantID string) {
|
|
isMaster := tenantID == "" || tenantID == MasterTenantID
|
|
|
|
// Collect targetIDs belonging to this tenant
|
|
var owned []string
|
|
for tid := range m.pages {
|
|
if isMaster {
|
|
// Master tenant owns pages not in pageTenants
|
|
if _, hasOwner := m.pageTenants[tid]; !hasOwner {
|
|
owned = append(owned, tid)
|
|
}
|
|
} else {
|
|
if m.pageTenants[tid] == tenantID {
|
|
owned = append(owned, tid)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(owned) < m.maxPages {
|
|
return
|
|
}
|
|
|
|
// Find the oldest page by lastUsed
|
|
var oldestID string
|
|
var oldestTime time.Time
|
|
for _, tid := range owned {
|
|
lu, ok := m.pageLastUsed[tid]
|
|
if !ok {
|
|
oldestID = tid
|
|
break
|
|
}
|
|
if oldestID == "" || lu.Before(oldestTime) {
|
|
oldestID = tid
|
|
oldestTime = lu
|
|
}
|
|
}
|
|
|
|
if oldestID == "" {
|
|
return
|
|
}
|
|
|
|
m.closeManagedPageLocked(oldestID)
|
|
m.logger.Info("evicted oldest page (max pages reached)", "targetId", oldestID, "tenant", tenantID)
|
|
}
|
|
|
|
// closeManagedPageLocked closes a page and removes all associated tracking.
|
|
// On Lightpanda, closing the dedicated CDP connection tears down the browser
|
|
// server-side (no page.Close() needed). Must be called with mu held.
|
|
func (m *Manager) closeManagedPageLocked(targetID string) {
|
|
if conn, ok := m.pageConns[targetID]; ok {
|
|
// Lightpanda: rod.Browser.Close() calls Browser.close which Lightpanda
|
|
// rejects with UnknownMethod; the WS drops anyway and Lightpanda
|
|
// auto-cleans the browser. Swallow the error.
|
|
_ = conn.Close()
|
|
delete(m.pageConns, targetID)
|
|
} else if page, ok := m.pages[targetID]; ok {
|
|
_ = page.Close()
|
|
}
|
|
delete(m.pages, targetID)
|
|
delete(m.pageInfos, targetID)
|
|
delete(m.console, targetID)
|
|
delete(m.pageTenants, targetID)
|
|
delete(m.pageLastUsed, targetID)
|
|
m.refs.Remove(targetID)
|
|
}
|
|
|
|
// FocusTab activates a tab.
|
|
func (m *Manager) FocusTab(ctx context.Context, targetID string) error {
|
|
tenantID := tenantIDFromCtx(ctx)
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
page, err := m.getPageForTenant(targetID, tenantID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = page.Activate()
|
|
return err
|
|
}
|
|
|
|
// CloseTab closes a tab.
|
|
func (m *Manager) CloseTab(ctx context.Context, targetID string) error {
|
|
tenantID := tenantIDFromCtx(ctx)
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Tenant ownership check — same semantics as getPageForTenant but without
|
|
// the upstream refresh, so it works uniformly for both backends.
|
|
if tenantID != "" && tenantID != MasterTenantID {
|
|
if owner, ok := m.pageTenants[targetID]; ok && owner != tenantID {
|
|
return fmt.Errorf("tab not found: %s", targetID)
|
|
}
|
|
}
|
|
if _, ok := m.pages[targetID]; !ok {
|
|
return fmt.Errorf("tab not found: %s", targetID)
|
|
}
|
|
|
|
m.closeManagedPageLocked(targetID)
|
|
return nil
|
|
}
|
|
|
|
// ConsoleMessages returns captured console messages for a tab.
|
|
func (m *Manager) ConsoleMessages(ctx context.Context, targetID string) []ConsoleMessage {
|
|
tenantID := tenantIDFromCtx(ctx)
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Validate tenant ownership
|
|
if tenantID != "" && tenantID != MasterTenantID {
|
|
if owner, ok := m.pageTenants[targetID]; ok && owner != tenantID {
|
|
return []ConsoleMessage{}
|
|
}
|
|
}
|
|
|
|
msgs := m.console[targetID]
|
|
if msgs == nil {
|
|
return []ConsoleMessage{}
|
|
}
|
|
|
|
// Return copy and clear
|
|
result := make([]ConsoleMessage, len(msgs))
|
|
copy(result, msgs)
|
|
m.console[targetID] = nil
|
|
return result
|
|
}
|