From c8fe5fc8c10fbefedbbf677d532389796bd80bad Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:59:29 -0700 Subject: [PATCH] refactor(browser): split browser and browser-IPC modules under the max-lines budget (#14697) The five oversized src/main/browser modules and src/main/ipc/browser.ts each carried a file-level `eslint-disable max-lines` and ran 377-654 counted lines against a 300-line budget. AGENTS.md calls for splitting rather than suppressing, and config/max-lines-baseline.txt is a shrink-only ratchet, so this removes all six suppressions and prunes their entries (341 -> 335). Pure move, no behavior change. cdp-ws-proxy is decomposed into collaborating objects rather than free functions because its state is genuinely per-connection: every collaborator is a private readonly instance field built in the constructor with live closures over `this`, so per-connection state stays per-connection. Likewise the screencast pacer's isClosed/isStopping and snapshot capture's getSeq are live thunks, not values captured at wiring time, so guards inside already-armed timers still observe a later stop(). browser-guest-ui.ts is renamed to browser-guest-shortcut-forwarding.ts: after the split it exports exactly one function, setupGuestShortcutForwarding, so the old name no longer described its contents. Also restores a single `webContents.debugger` read in the screencast path. The extraction had left three reads where the original had one; the accessor is stable today, so this is not a behavior fix but it removes a latent divergence. Verified: oxlint clean, ratchet passes, typecheck clean, full unit suite green (remaining failures are pre-existing load flakes in untouched files, each green when re-run serially), no new runtime import cycles, and the IPC channel set diffed identical before/after with all 23 handlers still trust-gated. --- config/max-lines-baseline.txt | 6 - .../browser/browser-guest-context-menu.ts | 92 +++ .../browser/browser-guest-grab-shortcuts.ts | 84 +++ .../browser/browser-guest-renderer-target.ts | 18 + .../browser-guest-shortcut-dispatch.ts | 210 ++++++ ...browser-guest-shortcut-forwarding.test.ts} | 8 +- .../browser-guest-shortcut-forwarding.ts | 157 +++++ src/main/browser/browser-guest-ui.ts | 600 ----------------- src/main/browser/browser-guest-wheel-zoom.ts | 92 +++ src/main/browser/browser-manager.ts | 12 +- .../browser/browser-screencast-cdp-events.ts | 103 +++ .../browser-screencast-debugger-command.ts | 25 + .../browser-screencast-device-metrics.ts | 56 ++ .../browser-screencast-frame-metadata.ts | 70 ++ .../browser/browser-screencast-frame-pacer.ts | 153 +++++ .../browser-screencast-snapshot-capture.ts | 162 +++++ .../browser-screencast-stream-types.ts | 37 ++ src/main/browser/browser-screencast-stream.ts | 609 ++---------------- .../browser-screencast-viewport-fit.ts | 91 +++ .../browser/browser-session-cookie-staging.ts | 137 ++++ .../browser/browser-session-meta-store.ts | 66 ++ .../browser-session-partition-policies.ts | 126 ++++ ...er-session-persisted-profile-validation.ts | 38 ++ src/main/browser/browser-session-registry.ts | 354 ++-------- .../browser/cdp-client-response-writer.ts | 58 ++ src/main/browser/cdp-debugger-channel.ts | 126 ++++ src/main/browser/cdp-dom-focus-replay.ts | 116 ++++ src/main/browser/cdp-page-capture-commands.ts | 82 +++ .../browser/cdp-page-navigation-commands.ts | 105 +++ .../browser/cdp-synthetic-session-registry.ts | 69 ++ src/main/browser/cdp-target-discovery.ts | 117 ++++ src/main/browser/cdp-ws-proxy.test.ts | 4 +- src/main/browser/cdp-ws-proxy.ts | 605 +++-------------- src/main/browser/snapshot-ax-tree-walk.ts | 208 ++++++ .../snapshot-cursor-interactive-elements.ts | 102 +++ src/main/browser/snapshot-engine.ts | 312 +-------- src/main/ipc/browser-grab-ipc.ts | 179 +++++ src/main/ipc/browser-guest-view-ipc.ts | 99 +++ src/main/ipc/browser-renderer-trust.ts | 25 + .../ipc/browser-session-profile-ipc.test.ts | 3 +- src/main/ipc/browser-session-profile-ipc.ts | 194 ++++++ src/main/ipc/browser-tab-registration-wait.ts | 122 ++++ src/main/ipc/browser.test.ts | 5 +- src/main/ipc/browser.ts | 603 +---------------- src/main/ipc/register-core-handlers.test.ts | 5 +- src/main/ipc/register-core-handlers.ts | 7 +- src/main/runtime/orca-runtime-browser.test.ts | 2 +- src/main/runtime/orca-runtime-browser.ts | 11 +- 48 files changed, 3528 insertions(+), 2937 deletions(-) create mode 100644 src/main/browser/browser-guest-context-menu.ts create mode 100644 src/main/browser/browser-guest-grab-shortcuts.ts create mode 100644 src/main/browser/browser-guest-renderer-target.ts create mode 100644 src/main/browser/browser-guest-shortcut-dispatch.ts rename src/main/browser/{browser-guest-ui.test.ts => browser-guest-shortcut-forwarding.test.ts} (99%) create mode 100644 src/main/browser/browser-guest-shortcut-forwarding.ts delete mode 100644 src/main/browser/browser-guest-ui.ts create mode 100644 src/main/browser/browser-guest-wheel-zoom.ts create mode 100644 src/main/browser/browser-screencast-cdp-events.ts create mode 100644 src/main/browser/browser-screencast-debugger-command.ts create mode 100644 src/main/browser/browser-screencast-device-metrics.ts create mode 100644 src/main/browser/browser-screencast-frame-metadata.ts create mode 100644 src/main/browser/browser-screencast-frame-pacer.ts create mode 100644 src/main/browser/browser-screencast-snapshot-capture.ts create mode 100644 src/main/browser/browser-screencast-stream-types.ts create mode 100644 src/main/browser/browser-screencast-viewport-fit.ts create mode 100644 src/main/browser/browser-session-cookie-staging.ts create mode 100644 src/main/browser/browser-session-meta-store.ts create mode 100644 src/main/browser/browser-session-partition-policies.ts create mode 100644 src/main/browser/browser-session-persisted-profile-validation.ts create mode 100644 src/main/browser/cdp-client-response-writer.ts create mode 100644 src/main/browser/cdp-debugger-channel.ts create mode 100644 src/main/browser/cdp-dom-focus-replay.ts create mode 100644 src/main/browser/cdp-page-capture-commands.ts create mode 100644 src/main/browser/cdp-page-navigation-commands.ts create mode 100644 src/main/browser/cdp-synthetic-session-registry.ts create mode 100644 src/main/browser/cdp-target-discovery.ts create mode 100644 src/main/browser/snapshot-ax-tree-walk.ts create mode 100644 src/main/browser/snapshot-cursor-interactive-elements.ts create mode 100644 src/main/ipc/browser-grab-ipc.ts create mode 100644 src/main/ipc/browser-guest-view-ipc.ts create mode 100644 src/main/ipc/browser-renderer-trust.ts create mode 100644 src/main/ipc/browser-session-profile-ipc.ts create mode 100644 src/main/ipc/browser-tab-registration-wait.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 0a6c64cd073..e965241c036 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -12,14 +12,9 @@ inline src/main/automations/external-manager.ts inline src/main/automations/hermes-cron-output.ts inline src/main/browser/agent-browser-bridge.ts inline src/main/browser/browser-cookie-import.ts -inline src/main/browser/browser-guest-ui.ts inline src/main/browser/browser-manager.ts -inline src/main/browser/browser-screencast-stream.ts -inline src/main/browser/browser-session-registry.ts inline src/main/browser/cdp-bridge.ts -inline src/main/browser/cdp-ws-proxy.ts inline src/main/browser/grab-guest-script.ts -inline src/main/browser/snapshot-engine.ts inline src/main/claude-accounts/runtime-auth-service.ts inline src/main/claude-accounts/service.ts inline src/main/claude-usage/scanner.ts @@ -57,7 +52,6 @@ inline src/main/gitlab/work-item-details.ts inline src/main/hermes/hook-service.ts inline src/main/hooks.ts inline src/main/index.ts -inline src/main/ipc/browser.ts inline src/main/ipc/crash-reporting.ts inline src/main/ipc/filesystem-auth.ts inline src/main/ipc/filesystem-mutations.ts diff --git a/src/main/browser/browser-guest-context-menu.ts b/src/main/browser/browser-guest-context-menu.ts new file mode 100644 index 00000000000..cbd774572dc --- /dev/null +++ b/src/main/browser/browser-guest-context-menu.ts @@ -0,0 +1,92 @@ +import { screen } from 'electron' +import { + normalizeBrowserNavigationUrl, + normalizeExternalBrowserUrl, + redactKagiSessionToken +} from '../../shared/browser-url' +import { readGuestNavigationState } from './browser-guest-navigation-state' +import type { ResolveRenderer } from './browser-guest-renderer-target' + +export function setupGuestContextMenu(args: { + browserTabId: string + guest: Electron.WebContents + resolveRenderer: ResolveRenderer +}): () => void { + const { browserTabId, guest, resolveRenderer } = args + const handler = (_event: Electron.Event, params: Electron.ContextMenuParams): void => { + const renderer = resolveRenderer(browserTabId) + if (!renderer) { + return + } + // Why: redact the Kagi session token before pageUrl leaves main — the renderer pipes it into clipboard and shell.openExternal. + const pageUrl = redactKagiSessionToken(guest.getURL()) + // Why: empty linkURL normalized would yield the truthy blank-page constant, showing "Open Link…" on every non-link right-click. + const rawLinkUrl = params.linkURL || '' + const linkUrl = + rawLinkUrl.length > 0 + ? (normalizeExternalBrowserUrl(rawLinkUrl) ?? normalizeBrowserNavigationUrl(rawLinkUrl)) + : null + // Why: send both viewport and screen-cursor coords; screen cursor avoids coordinate-space mismatch, guest coords are the fallback. + const cursor = screen.getCursorScreenPoint() + const navigationState = readGuestNavigationState(guest) + renderer.send('browser:context-menu-requested', { + browserPageId: browserTabId, + x: params.x, + y: params.y, + screenX: cursor.x, + screenY: cursor.y, + pageUrl, + linkUrl, + // Why: forward the native selection so the renderer can Copy it directly, bypassing pages that suppress copy via oncopy handlers. + selectionText: params.selectionText ?? '', + ...navigationState + }) + } + + // Why: before-mouse-event fires on every move/scroll; install the dismiss listener only while a menu is open to avoid per-event IPC. + let dismissHandler: ((_event: Electron.Event, mouse: Electron.MouseInputEvent) => void) | null = + null + + const removeDismissListener = (): void => { + if (dismissHandler) { + try { + guest.off('before-mouse-event', dismissHandler) + } catch { + /* guest may already be destroyed */ + } + dismissHandler = null + } + } + + const contextMenuHandler = (_event: Electron.Event, params: Electron.ContextMenuParams): void => { + handler(_event, params) + + removeDismissListener() + dismissHandler = (_evt: Electron.Event, mouse: Electron.MouseInputEvent): void => { + if (mouse.type !== 'mouseDown') { + return + } + // Why: a right-click mouseDown precedes a new context-menu event; dismissing here flashes the menu closed then reopens it at 0,0. + if (mouse.button === 'right') { + return + } + const renderer = resolveRenderer(browserTabId) + if (renderer) { + renderer.send('browser:context-menu-dismissed', { browserPageId: browserTabId }) + } + removeDismissListener() + } + guest.on('before-mouse-event', dismissHandler) + } + + guest.on('context-menu', contextMenuHandler) + + return () => { + try { + guest.off('context-menu', contextMenuHandler) + removeDismissListener() + } catch { + // Why: browser tabs can briefly outlive the guest webContents during teardown, so cleanup is best-effort. + } + } +} diff --git a/src/main/browser/browser-guest-grab-shortcuts.ts b/src/main/browser/browser-guest-grab-shortcuts.ts new file mode 100644 index 00000000000..4ab6c61a6d4 --- /dev/null +++ b/src/main/browser/browser-guest-grab-shortcuts.ts @@ -0,0 +1,84 @@ +import { keybindingMatchesAction, type KeybindingOverrides } from '../../shared/keybindings' +import type { ResolveRenderer } from './browser-guest-renderer-target' + +// Why: a focused guest never surfaces Cmd/Ctrl+C to the renderer; forward only when it wouldn't do a normal copy (no editable focus, no selection). +export function setupGrabShortcutForwarding(args: { + browserTabId: string + guest: Electron.WebContents + resolveRenderer: ResolveRenderer + hasActiveGrabOp: (browserTabId: string) => boolean + getKeybindings?: () => KeybindingOverrides | undefined +}): () => void { + const { browserTabId, guest, resolveRenderer, hasActiveGrabOp, getKeybindings } = args + const handler = (event: Electron.Event, input: Electron.Input): void => { + if (input.type !== 'keyDown') { + return + } + const bareKey = input.key.toLowerCase() + if ( + !input.meta && + !input.control && + !input.alt && + !input.shift && + (bareKey === 'c' || bareKey === 's') && + hasActiveGrabOp(browserTabId) + ) { + const renderer = resolveRenderer(browserTabId) + if (!renderer) { + return + } + // Why: a focused guest swallows bare keys; during an active grab pick, plain C/S are Orca's copy/screenshot, not page typing. + event.preventDefault() + renderer.send('browser:grabActionShortcut', { browserPageId: browserTabId, key: bareKey }) + return + } + + if ( + !keybindingMatchesAction('browser.grabElement', input, process.platform, getKeybindings?.()) + ) { + return + } + + void guest + .executeJavaScript(`(() => { + const active = document.activeElement + const tag = active?.tagName + const isEditable = + active instanceof HTMLInputElement || + active instanceof HTMLTextAreaElement || + active?.isContentEditable === true || + tag === 'SELECT' || + tag === 'IFRAME' + if (isEditable) { + return false + } + const selection = window.getSelection() + return Boolean(selection && selection.type === 'Range' && selection.toString().trim().length > 0) + ? false + : true + })()`) + .then((shouldToggle) => { + if (!shouldToggle) { + return + } + event.preventDefault() + const renderer = resolveRenderer(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:grabModeToggle', browserTabId) + }) + .catch(() => { + // Why: shortcut forwarding is best-effort — guest teardown or a transient executeJavaScript failure must not break normal copy. + }) + } + + guest.on('before-input-event', handler) + return () => { + try { + guest.off('before-input-event', handler) + } catch { + // Why: browser tabs can briefly outlive the guest webContents during teardown, so cleanup is best-effort. + } + } +} diff --git a/src/main/browser/browser-guest-renderer-target.ts b/src/main/browser/browser-guest-renderer-target.ts new file mode 100644 index 00000000000..68057e0bdd9 --- /dev/null +++ b/src/main/browser/browser-guest-renderer-target.ts @@ -0,0 +1,18 @@ +import { webContents } from 'electron' + +export type ResolveRenderer = (browserTabId: string) => Electron.WebContents | null + +export function resolveRendererWebContents( + rendererWebContentsIdByTabId: ReadonlyMap, + browserTabId: string +): Electron.WebContents | null { + const rendererWcId = rendererWebContentsIdByTabId.get(browserTabId) + if (!rendererWcId) { + return null + } + const renderer = webContents.fromId(rendererWcId) + if (!renderer || renderer.isDestroyed()) { + return null + } + return renderer +} diff --git a/src/main/browser/browser-guest-shortcut-dispatch.ts b/src/main/browser/browser-guest-shortcut-dispatch.ts new file mode 100644 index 00000000000..f898b270116 --- /dev/null +++ b/src/main/browser/browser-guest-shortcut-dispatch.ts @@ -0,0 +1,210 @@ +import { + resolveWindowShortcutAction, + type WindowShortcutInput +} from '../../shared/window-shortcut-policy' +import { keybindingMatchesAction, type KeybindingOverrides } from '../../shared/keybindings' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import type { BrowserPageZoomDirection } from '../../shared/browser-page-zoom' +import type { BrowserFindSource } from '../../shared/browser-find-source' +import type { ResolveRenderer } from './browser-guest-renderer-target' + +export type ShouldForwardDictationShortcut = () => boolean +export type IsMobileEmulatorEnabled = () => boolean +export type GuestShortcutInput = WindowShortcutInput & { isAutoRepeat?: boolean } + +export type GuestShortcutForwardContext = { + browserTabId: string + resolveRenderer: ResolveRenderer + shouldForwardDictationShortcut?: ShouldForwardDictationShortcut + isMobileEmulatorEnabled?: IsMobileEmulatorEnabled + getKeybindings?: () => KeybindingOverrides | undefined + resolveWorktreeId?: (browserTabId: string) => string | null + resolveWorkspaceId?: (browserTabId: string) => string | null + forwardBrowserPageZoom: (event: Electron.Event, direction: BrowserPageZoomDirection) => void +} + +export function forwardGuestShortcutInput( + ctx: GuestShortcutForwardContext, + event: Electron.Event, + input: GuestShortcutInput, + action = resolveWindowShortcutAction(input, process.platform, ctx.getKeybindings?.()) +): boolean { + const { + browserTabId, + resolveRenderer, + shouldForwardDictationShortcut, + isMobileEmulatorEnabled, + getKeybindings, + resolveWorktreeId, + resolveWorkspaceId, + forwardBrowserPageZoom + } = ctx + const keybindings = getKeybindings?.() + if (action?.type === 'zoom') { + // Why: focused guest key events never reach the renderer-owned webview ref that applies Orca's page zoom. + forwardBrowserPageZoom(event, action.direction) + return true + } + if (input.isAutoRepeat) { + if (action?.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) { + event.preventDefault() + return true + } + return false + } + if (action?.type === 'worktreeHistoryNavigate') { + // Why: preventDefault unconditionally so the guest never handles Cmd+Alt+Arrow itself, even when the renderer can't be resolved. + event.preventDefault() + const renderer = resolveRenderer(browserTabId) + renderer?.send('ui:worktreeHistoryNavigate', action.direction) + return true + } + + if (action?.type === 'toggleFloatingTerminal') { + event.preventDefault() + const renderer = resolveRenderer(browserTabId) + renderer?.send('ui:toggleFloatingTerminal') + return true + } + + // Why: match outside the allowlist so both the new Shift binding and upgraders' seeded Alt binding reach the renderer. + const switchAllTypesDirection = keybindingMatchesAction( + 'tab.nextAllTypes', + input, + process.platform, + keybindings + ) + ? 1 + : keybindingMatchesAction('tab.previousAllTypes', input, process.platform, keybindings) + ? -1 + : null + if (switchAllTypesDirection !== null) { + event.preventDefault() + const renderer = resolveRenderer(browserTabId) + renderer?.send('ui:switchTabAcrossAllTypes', switchAllTypesDirection) + return true + } + + if (keybindingMatchesAction('tab.previousRecent', input, process.platform, keybindings)) { + event.preventDefault() + const renderer = resolveRenderer(browserTabId) + renderer?.send('ui:switchRecentTab') + return true + } + + // Why: terminal-tab switching defaults to Ctrl+PageUp/PageDown but goes through the registry so disable/rebind still works. + const terminalTabDirection = keybindingMatchesAction( + 'tab.nextTerminal', + input, + process.platform, + keybindings + ) + ? 1 + : keybindingMatchesAction('tab.previousTerminal', input, process.platform, keybindings) + ? -1 + : null + if (terminalTabDirection !== null) { + event.preventDefault() + const renderer = resolveRenderer(browserTabId) + renderer?.send('ui:switchTerminalTab', terminalTabDirection) + return true + } + + const renderer = resolveRenderer(browserTabId) + if (!renderer) { + return false + } + // Why: floating-panel guests route close/index chords to the panel (carrying their source id) so they hit the floating workspace, not the main tab strip. + const isFloatingGuest = resolveWorktreeId?.(browserTabId) === FLOATING_TERMINAL_WORKTREE_ID + if (keybindingMatchesAction('tab.newBrowser', input, process.platform, keybindings)) { + renderer.send('ui:newBrowserTab') + } else if ( + process.platform === 'darwin' && + (isMobileEmulatorEnabled?.() ?? true) && + keybindingMatchesAction('tab.newSimulator', input, process.platform, keybindings) + ) { + renderer.send('ui:newSimulatorTab') + } else if (keybindingMatchesAction('tab.newMarkdown', input, process.platform, keybindings)) { + renderer.send('ui:newMarkdownTab') + } else if (keybindingMatchesAction('tab.newTerminal', input, process.platform, keybindings)) { + // Why: Cmd/Ctrl+T opens a terminal even when a browser guest is focused (Shift+B is the new-browser-tab shortcut). + renderer.send('ui:newTerminalTab') + } else if ( + keybindingMatchesAction('browser.focusAddressBar', input, process.platform, keybindings) + ) { + // Why: the address bar lives in renderer chrome, not the guest page; forward so the active BrowserPane can focus its input. + renderer.send('ui:focusBrowserAddressBar') + } else if (keybindingMatchesAction('browser.hardReload', input, process.platform, keybindings)) { + // Why: forward hard reload so reloadIgnoringCache() runs on the renderer's parked-webview ref that owns the guest surface. + renderer.send('ui:hardReloadBrowserPage') + } else if (keybindingMatchesAction('browser.reload', input, process.platform, keybindings)) { + // Why: forward soft reload so the renderer's reload() hits the parked-webview eviction the guest's built-in shortcut skips. + renderer.send('ui:reloadBrowserPage') + } else if (keybindingMatchesAction('browser.find', input, process.platform, keybindings)) { + const browserWorkspaceId = resolveWorkspaceId?.(browserTabId) + if (browserWorkspaceId) { + const source: BrowserFindSource = { + browserPageId: browserTabId, + browserWorkspaceId + } + // Why: active browser splits share one renderer; preserve the registered guest owner so only its Find bar opens. + renderer.send('ui:findInBrowserPage', source) + } + } else if (keybindingMatchesAction('browser.back', input, process.platform, keybindings)) { + // Why: macOS Logitech side-button remaps arrive as history keystrokes, not mouse events; forward so the renderer can goBack(). + renderer.send('ui:browserHistoryNavigate', 'back') + } else if (keybindingMatchesAction('browser.forward', input, process.platform, keybindings)) { + // Why: same as browser.back; the focused guest cannot call the renderer-owned webview's goForward() directly. + renderer.send('ui:browserHistoryNavigate', 'forward') + } else if (keybindingMatchesAction('tab.close', input, process.platform, keybindings)) { + if (isFloatingGuest) { + renderer.send('ui:closeFloatingItem', { sourceId: browserTabId }) + } else { + renderer.send('ui:closeActiveTab') + } + } else if (keybindingMatchesAction('tab.nextSameType', input, process.platform, keybindings)) { + renderer.send('ui:switchTab', 1) + } else if ( + keybindingMatchesAction('tab.previousSameType', input, process.platform, keybindings) + ) { + renderer.send('ui:switchTab', -1) + } else if (action?.type === 'toggleWorktreePalette') { + renderer.send('ui:toggleWorktreePalette') + } else if (action?.type === 'openQuickOpen') { + renderer.send('ui:openQuickOpen') + } else if (action?.type === 'toggleQuickCommandsMenu') { + renderer.send('ui:toggleQuickCommandsMenu') + } else if (action?.type === 'openNewWorkspace') { + renderer.send('ui:openNewWorkspace') + } else if (action?.type === 'openWorkspaceBoard') { + renderer.send('ui:openWorkspaceBoard') + } else if (action?.type === 'openTasks') { + renderer.send('ui:openTasks') + } else if (action?.type === 'openSettings') { + renderer.send('ui:openSettings') + } else if (action?.type === 'forceReload') { + renderer.reloadIgnoringCache() + } else if (action?.type === 'jumpToWorktreeIndex') { + if (isFloatingGuest) { + renderer.send('ui:selectFloatingIndex', { index: action.index }) + } else { + renderer.send('ui:jumpToWorktreeIndex', action.index) + } + } else if (action?.type === 'jumpToTabIndex') { + if (isFloatingGuest) { + renderer.send('ui:selectFloatingIndex', { index: action.index }) + } else { + renderer.send('ui:jumpToTabIndex', action.index) + } + } else if (action?.type === 'dictationKeyDown') { + if (!shouldForwardDictationShortcut?.()) { + return false + } + renderer.send('ui:dictationKeyDown') + } else { + return false + } + // Why: preventDefault stops the guest page from also processing the chord (e.g. Cmd+T opening a browser-internal new-tab page). + event.preventDefault() + return true +} diff --git a/src/main/browser/browser-guest-ui.test.ts b/src/main/browser/browser-guest-shortcut-forwarding.test.ts similarity index 99% rename from src/main/browser/browser-guest-ui.test.ts rename to src/main/browser/browser-guest-shortcut-forwarding.test.ts index 946ed38a7ef..39a24fa45fc 100644 --- a/src/main/browser/browser-guest-ui.test.ts +++ b/src/main/browser/browser-guest-shortcut-forwarding.test.ts @@ -11,10 +11,10 @@ vi.mock('electron', () => ({ import { resolveGuestMouseWheelZoomDirection, - setupGuestContextMenu, - setupGuestMouseWheelZoomForwarding, - setupGuestShortcutForwarding -} from './browser-guest-ui' + setupGuestMouseWheelZoomForwarding +} from './browser-guest-wheel-zoom' +import { setupGuestContextMenu } from './browser-guest-context-menu' +import { setupGuestShortcutForwarding } from './browser-guest-shortcut-forwarding' describe('setupGuestContextMenu', () => { const browserTabId = 'tab-1' diff --git a/src/main/browser/browser-guest-shortcut-forwarding.ts b/src/main/browser/browser-guest-shortcut-forwarding.ts new file mode 100644 index 00000000000..2117599f21f --- /dev/null +++ b/src/main/browser/browser-guest-shortcut-forwarding.ts @@ -0,0 +1,157 @@ +import { + isRecentTabSwitcherCommitRelease, + matchesRecentTabSwitcherChord, + nativeZoomCommandMatchesKeybindings, + resolveWindowShortcutAction +} from '../../shared/window-shortcut-policy' +import type { KeybindingOverrides } from '../../shared/keybindings' +import type { BrowserPageZoomDirection } from '../../shared/browser-page-zoom' +import { + ModifierDoubleTapDetector, + toModifierDoubleTapEvent +} from '../../shared/modifier-double-tap-detector' +import type { ResolveRenderer } from './browser-guest-renderer-target' +import { consumeRecentGuestWheelZoom } from './browser-guest-wheel-zoom' +import { + forwardGuestShortcutInput, + type GuestShortcutForwardContext, + type GuestShortcutInput, + type IsMobileEmulatorEnabled, + type ShouldForwardDictationShortcut +} from './browser-guest-shortcut-dispatch' + +// Why: a focused webview guest is its own Chromium process whose key events never reach the renderer; forward shortcuts from here. +export function setupGuestShortcutForwarding(args: { + browserTabId: string + guest: Electron.WebContents + resolveRenderer: ResolveRenderer + shouldForwardDictationShortcut?: ShouldForwardDictationShortcut + isMobileEmulatorEnabled?: IsMobileEmulatorEnabled + getKeybindings?: () => KeybindingOverrides | undefined + // Why: a floating-panel guest owns a distinct workspace; its close/index chords must route to the panel, not the main tab strip. + resolveWorktreeId?: (browserTabId: string) => string | null + resolveWorkspaceId?: (browserTabId: string) => string | null +}): () => void { + const { + browserTabId, + guest, + resolveRenderer, + shouldForwardDictationShortcut, + isMobileEmulatorEnabled, + getKeybindings, + resolveWorktreeId, + resolveWorkspaceId + } = args + let ctrlTabSwitching = false + const doubleTapDetector = new ModifierDoubleTapDetector() + const resetDoubleTapDetector = (): void => doubleTapDetector.reset() + + const forwardBrowserPageZoom = ( + event: Electron.Event, + direction: BrowserPageZoomDirection + ): void => { + event.preventDefault() + const renderer = resolveRenderer(browserTabId) + renderer?.send('ui:zoomBrowserPage', direction) + } + + const forwardContext: GuestShortcutForwardContext = { + browserTabId, + resolveRenderer, + shouldForwardDictationShortcut, + isMobileEmulatorEnabled, + getKeybindings, + resolveWorktreeId, + resolveWorkspaceId, + forwardBrowserPageZoom + } + + const handler = (event: Electron.Event, input: Electron.Input): void => { + const keybindings = getKeybindings?.() + if ( + input.type === 'keyDown' && + matchesRecentTabSwitcherChord(input, process.platform, keybindings) + ) { + // Why: held switcher commits on Control keyup; preventDefault on Tab + // keydown suppresses that keyup in Electron and strands the overlay. + ctrlTabSwitching = true + const renderer = resolveRenderer(browserTabId) + renderer?.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true }) + return + } + + if (ctrlTabSwitching && isRecentTabSwitcherCommitRelease(input)) { + event.preventDefault() + ctrlTabSwitching = false + const renderer = resolveRenderer(browserTabId) + renderer?.send('ui:ctrlTabKeyUp') + return + } + + if (input.type === 'keyDown' || input.type === 'keyUp') { + const detected = doubleTapDetector.process( + toModifierDoubleTapEvent({ + type: input.type, + code: input.code, + key: input.key, + shift: input.shift, + control: input.control, + alt: input.alt, + meta: input.meta, + isAutoRepeat: input.isAutoRepeat + }), + Date.now() + ) + if (detected) { + const doubleTapInput: GuestShortcutInput = { doubleTapModifier: detected.modifier } + forwardGuestShortcutInput( + forwardContext, + event, + doubleTapInput, + resolveWindowShortcutAction(doubleTapInput, process.platform, keybindings, { + context: 'app' + }) + ) + return + } + } + + if (input.type !== 'keyDown') { + return + } + // Why: Cmd/Ctrl+Alt+Arrow is the only allowlisted chord carrying Alt, so resolve it before the Alt-rejecting chord gate below. + const action = resolveWindowShortcutAction(input, process.platform, keybindings) + forwardGuestShortcutInput(forwardContext, event, input, action) + } + + const zoomCommandHandler = ( + event: Electron.Event, + zoomDirection: 'in' | 'out' | 'reset' + ): void => { + if (zoomDirection !== 'in' && zoomDirection !== 'out') { + return + } + // Why: some layouts/platforms turn Ctrl/Cmd +/- into Electron's native zoom before before-input-event reaches the guest. + if (consumeRecentGuestWheelZoom(guest, zoomDirection)) { + event.preventDefault() + return + } + if (!nativeZoomCommandMatchesKeybindings(zoomDirection, process.platform, getKeybindings?.())) { + return + } + forwardBrowserPageZoom(event, zoomDirection) + } + + guest.on('before-input-event', handler) + guest.on('zoom-changed', zoomCommandHandler) + guest.on('blur', resetDoubleTapDetector) + return () => { + try { + guest.off('before-input-event', handler) + guest.off('zoom-changed', zoomCommandHandler) + guest.off('blur', resetDoubleTapDetector) + } catch { + // Why: best-effort — guest may already be destroyed during teardown. + } + } +} diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts deleted file mode 100644 index b9c52b39aa7..00000000000 --- a/src/main/browser/browser-guest-ui.ts +++ /dev/null @@ -1,600 +0,0 @@ -/* eslint-disable max-lines -- Why: single privileged bridge for guest context menus, grab-mode, and app-shortcut forwarding; splitting would blur the security boundary. */ -import { screen, webContents } from 'electron' -import { - normalizeBrowserNavigationUrl, - normalizeExternalBrowserUrl, - redactKagiSessionToken -} from '../../shared/browser-url' -import { - isRecentTabSwitcherCommitRelease, - matchesRecentTabSwitcherChord, - nativeZoomCommandMatchesKeybindings, - resolveWindowShortcutAction, - type WindowShortcutInput -} from '../../shared/window-shortcut-policy' -import { readGuestNavigationState } from './browser-guest-navigation-state' -import { keybindingMatchesAction, type KeybindingOverrides } from '../../shared/keybindings' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' -import type { BrowserPageZoomDirection } from '../../shared/browser-page-zoom' -import { - ModifierDoubleTapDetector, - toModifierDoubleTapEvent -} from '../../shared/modifier-double-tap-detector' -import type { BrowserFindSource } from '../../shared/browser-find-source' - -type ResolveRenderer = (browserTabId: string) => Electron.WebContents | null -type ShouldForwardDictationShortcut = () => boolean -type IsMobileEmulatorEnabled = () => boolean - -const CONTROL_MODIFIERS = new Set(['control', 'ctrl']) -const MAC_COMMAND_MODIFIERS = new Set(['meta', 'command', 'cmd']) -const WHEEL_ZOOM_BLOCKING_MODIFIERS = new Set(['alt', 'shift']) -const GUEST_WHEEL_ZOOM_DEDUPE_MS = 250 - -type GuestWheelZoomDirection = Exclude - -const recentGuestWheelZoomByGuest = new WeakMap< - Electron.WebContents, - { direction: GuestWheelZoomDirection; at: number } ->() - -function markGuestWheelZoom(guest: Electron.WebContents, direction: GuestWheelZoomDirection): void { - recentGuestWheelZoomByGuest.set(guest, { direction, at: Date.now() }) -} - -function consumeRecentGuestWheelZoom( - guest: Electron.WebContents, - direction: GuestWheelZoomDirection -): boolean { - const recent = recentGuestWheelZoomByGuest.get(guest) - if (!recent) { - return false - } - const elapsed = Date.now() - recent.at - if (elapsed < 0 || elapsed > GUEST_WHEEL_ZOOM_DEDUPE_MS) { - recentGuestWheelZoomByGuest.delete(guest) - return false - } - if (recent.direction !== direction) { - return false - } - recentGuestWheelZoomByGuest.delete(guest) - return true -} - -function hasModifier(mouse: Electron.MouseInputEvent, modifiers: ReadonlySet): boolean { - return mouse.modifiers?.some((modifier) => modifiers.has(modifier)) ?? false -} - -export function resolveGuestMouseWheelZoomDirection( - mouse: Electron.MouseInputEvent, - platform: NodeJS.Platform = process.platform -): GuestWheelZoomDirection | null { - if (mouse.type !== 'mouseWheel') { - return null - } - if (hasModifier(mouse, WHEEL_ZOOM_BLOCKING_MODIFIERS)) { - return null - } - const hasZoomModifier = - hasModifier(mouse, CONTROL_MODIFIERS) || - (platform === 'darwin' && hasModifier(mouse, MAC_COMMAND_MODIFIERS)) - if (!hasZoomModifier) { - return null - } - const deltaY = (mouse as Electron.MouseWheelInputEvent).deltaY - if (typeof deltaY !== 'number' || deltaY === 0) { - return null - } - return deltaY < 0 ? 'in' : 'out' -} - -export function setupGuestContextMenu(args: { - browserTabId: string - guest: Electron.WebContents - resolveRenderer: ResolveRenderer -}): () => void { - const { browserTabId, guest, resolveRenderer } = args - const handler = (_event: Electron.Event, params: Electron.ContextMenuParams): void => { - const renderer = resolveRenderer(browserTabId) - if (!renderer) { - return - } - // Why: redact the Kagi session token before pageUrl leaves main — the renderer pipes it into clipboard and shell.openExternal. - const pageUrl = redactKagiSessionToken(guest.getURL()) - // Why: empty linkURL normalized would yield the truthy blank-page constant, showing "Open Link…" on every non-link right-click. - const rawLinkUrl = params.linkURL || '' - const linkUrl = - rawLinkUrl.length > 0 - ? (normalizeExternalBrowserUrl(rawLinkUrl) ?? normalizeBrowserNavigationUrl(rawLinkUrl)) - : null - // Why: send both viewport and screen-cursor coords; screen cursor avoids coordinate-space mismatch, guest coords are the fallback. - const cursor = screen.getCursorScreenPoint() - const navigationState = readGuestNavigationState(guest) - renderer.send('browser:context-menu-requested', { - browserPageId: browserTabId, - x: params.x, - y: params.y, - screenX: cursor.x, - screenY: cursor.y, - pageUrl, - linkUrl, - // Why: forward the native selection so the renderer can Copy it directly, bypassing pages that suppress copy via oncopy handlers. - selectionText: params.selectionText ?? '', - ...navigationState - }) - } - - // Why: before-mouse-event fires on every move/scroll; install the dismiss listener only while a menu is open to avoid per-event IPC. - let dismissHandler: ((_event: Electron.Event, mouse: Electron.MouseInputEvent) => void) | null = - null - - const removeDismissListener = (): void => { - if (dismissHandler) { - try { - guest.off('before-mouse-event', dismissHandler) - } catch { - /* guest may already be destroyed */ - } - dismissHandler = null - } - } - - const contextMenuHandler = (_event: Electron.Event, params: Electron.ContextMenuParams): void => { - handler(_event, params) - - removeDismissListener() - dismissHandler = (_evt: Electron.Event, mouse: Electron.MouseInputEvent): void => { - if (mouse.type !== 'mouseDown') { - return - } - // Why: a right-click mouseDown precedes a new context-menu event; dismissing here flashes the menu closed then reopens it at 0,0. - if (mouse.button === 'right') { - return - } - const renderer = resolveRenderer(browserTabId) - if (renderer) { - renderer.send('browser:context-menu-dismissed', { browserPageId: browserTabId }) - } - removeDismissListener() - } - guest.on('before-mouse-event', dismissHandler) - } - - guest.on('context-menu', contextMenuHandler) - - return () => { - try { - guest.off('context-menu', contextMenuHandler) - removeDismissListener() - } catch { - // Why: browser tabs can briefly outlive the guest webContents during teardown, so cleanup is best-effort. - } - } -} - -// Why: a focused guest never surfaces Cmd/Ctrl+C to the renderer; forward only when it wouldn't do a normal copy (no editable focus, no selection). -export function setupGrabShortcutForwarding(args: { - browserTabId: string - guest: Electron.WebContents - resolveRenderer: ResolveRenderer - hasActiveGrabOp: (browserTabId: string) => boolean - getKeybindings?: () => KeybindingOverrides | undefined -}): () => void { - const { browserTabId, guest, resolveRenderer, hasActiveGrabOp, getKeybindings } = args - const handler = (event: Electron.Event, input: Electron.Input): void => { - if (input.type !== 'keyDown') { - return - } - const bareKey = input.key.toLowerCase() - if ( - !input.meta && - !input.control && - !input.alt && - !input.shift && - (bareKey === 'c' || bareKey === 's') && - hasActiveGrabOp(browserTabId) - ) { - const renderer = resolveRenderer(browserTabId) - if (!renderer) { - return - } - // Why: a focused guest swallows bare keys; during an active grab pick, plain C/S are Orca's copy/screenshot, not page typing. - event.preventDefault() - renderer.send('browser:grabActionShortcut', { browserPageId: browserTabId, key: bareKey }) - return - } - - if ( - !keybindingMatchesAction('browser.grabElement', input, process.platform, getKeybindings?.()) - ) { - return - } - - void guest - .executeJavaScript(`(() => { - const active = document.activeElement - const tag = active?.tagName - const isEditable = - active instanceof HTMLInputElement || - active instanceof HTMLTextAreaElement || - active?.isContentEditable === true || - tag === 'SELECT' || - tag === 'IFRAME' - if (isEditable) { - return false - } - const selection = window.getSelection() - return Boolean(selection && selection.type === 'Range' && selection.toString().trim().length > 0) - ? false - : true - })()`) - .then((shouldToggle) => { - if (!shouldToggle) { - return - } - event.preventDefault() - const renderer = resolveRenderer(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:grabModeToggle', browserTabId) - }) - .catch(() => { - // Why: shortcut forwarding is best-effort — guest teardown or a transient executeJavaScript failure must not break normal copy. - }) - } - - guest.on('before-input-event', handler) - return () => { - try { - guest.off('before-input-event', handler) - } catch { - // Why: browser tabs can briefly outlive the guest webContents during teardown, so cleanup is best-effort. - } - } -} - -// Why: a focused webview guest is its own Chromium process whose key events never reach the renderer; forward shortcuts from here. -export function setupGuestShortcutForwarding(args: { - browserTabId: string - guest: Electron.WebContents - resolveRenderer: ResolveRenderer - shouldForwardDictationShortcut?: ShouldForwardDictationShortcut - isMobileEmulatorEnabled?: IsMobileEmulatorEnabled - getKeybindings?: () => KeybindingOverrides | undefined - // Why: a floating-panel guest owns a distinct workspace; its close/index chords must route to the panel, not the main tab strip. - resolveWorktreeId?: (browserTabId: string) => string | null - resolveWorkspaceId?: (browserTabId: string) => string | null -}): () => void { - const { - browserTabId, - guest, - resolveRenderer, - shouldForwardDictationShortcut, - isMobileEmulatorEnabled, - getKeybindings, - resolveWorktreeId, - resolveWorkspaceId - } = args - let ctrlTabSwitching = false - const doubleTapDetector = new ModifierDoubleTapDetector() - const resetDoubleTapDetector = (): void => doubleTapDetector.reset() - type GuestShortcutInput = WindowShortcutInput & { isAutoRepeat?: boolean } - - const forwardBrowserPageZoom = ( - event: Electron.Event, - direction: BrowserPageZoomDirection - ): void => { - event.preventDefault() - const renderer = resolveRenderer(browserTabId) - renderer?.send('ui:zoomBrowserPage', direction) - } - - const forwardShortcutInput = ( - event: Electron.Event, - input: GuestShortcutInput, - action = resolveWindowShortcutAction(input, process.platform, getKeybindings?.()) - ): boolean => { - const keybindings = getKeybindings?.() - if (action?.type === 'zoom') { - // Why: focused guest key events never reach the renderer-owned webview ref that applies Orca's page zoom. - forwardBrowserPageZoom(event, action.direction) - return true - } - if (input.isAutoRepeat) { - if (action?.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) { - event.preventDefault() - return true - } - return false - } - if (action?.type === 'worktreeHistoryNavigate') { - // Why: preventDefault unconditionally so the guest never handles Cmd+Alt+Arrow itself, even when the renderer can't be resolved. - event.preventDefault() - const renderer = resolveRenderer(browserTabId) - renderer?.send('ui:worktreeHistoryNavigate', action.direction) - return true - } - - if (action?.type === 'toggleFloatingTerminal') { - event.preventDefault() - const renderer = resolveRenderer(browserTabId) - renderer?.send('ui:toggleFloatingTerminal') - return true - } - - // Why: match outside the allowlist so both the new Shift binding and upgraders' seeded Alt binding reach the renderer. - const switchAllTypesDirection = keybindingMatchesAction( - 'tab.nextAllTypes', - input, - process.platform, - keybindings - ) - ? 1 - : keybindingMatchesAction('tab.previousAllTypes', input, process.platform, keybindings) - ? -1 - : null - if (switchAllTypesDirection !== null) { - event.preventDefault() - const renderer = resolveRenderer(browserTabId) - renderer?.send('ui:switchTabAcrossAllTypes', switchAllTypesDirection) - return true - } - - if (keybindingMatchesAction('tab.previousRecent', input, process.platform, keybindings)) { - event.preventDefault() - const renderer = resolveRenderer(browserTabId) - renderer?.send('ui:switchRecentTab') - return true - } - - // Why: terminal-tab switching defaults to Ctrl+PageUp/PageDown but goes through the registry so disable/rebind still works. - const terminalTabDirection = keybindingMatchesAction( - 'tab.nextTerminal', - input, - process.platform, - keybindings - ) - ? 1 - : keybindingMatchesAction('tab.previousTerminal', input, process.platform, keybindings) - ? -1 - : null - if (terminalTabDirection !== null) { - event.preventDefault() - const renderer = resolveRenderer(browserTabId) - renderer?.send('ui:switchTerminalTab', terminalTabDirection) - return true - } - - const renderer = resolveRenderer(browserTabId) - if (!renderer) { - return false - } - // Why: floating-panel guests route close/index chords to the panel (carrying their source id) so they hit the floating workspace, not the main tab strip. - const isFloatingGuest = resolveWorktreeId?.(browserTabId) === FLOATING_TERMINAL_WORKTREE_ID - if (keybindingMatchesAction('tab.newBrowser', input, process.platform, keybindings)) { - renderer.send('ui:newBrowserTab') - } else if ( - process.platform === 'darwin' && - (isMobileEmulatorEnabled?.() ?? true) && - keybindingMatchesAction('tab.newSimulator', input, process.platform, keybindings) - ) { - renderer.send('ui:newSimulatorTab') - } else if (keybindingMatchesAction('tab.newMarkdown', input, process.platform, keybindings)) { - renderer.send('ui:newMarkdownTab') - } else if (keybindingMatchesAction('tab.newTerminal', input, process.platform, keybindings)) { - // Why: Cmd/Ctrl+T opens a terminal even when a browser guest is focused (Shift+B is the new-browser-tab shortcut). - renderer.send('ui:newTerminalTab') - } else if ( - keybindingMatchesAction('browser.focusAddressBar', input, process.platform, keybindings) - ) { - // Why: the address bar lives in renderer chrome, not the guest page; forward so the active BrowserPane can focus its input. - renderer.send('ui:focusBrowserAddressBar') - } else if ( - keybindingMatchesAction('browser.hardReload', input, process.platform, keybindings) - ) { - // Why: forward hard reload so reloadIgnoringCache() runs on the renderer's parked-webview ref that owns the guest surface. - renderer.send('ui:hardReloadBrowserPage') - } else if (keybindingMatchesAction('browser.reload', input, process.platform, keybindings)) { - // Why: forward soft reload so the renderer's reload() hits the parked-webview eviction the guest's built-in shortcut skips. - renderer.send('ui:reloadBrowserPage') - } else if (keybindingMatchesAction('browser.find', input, process.platform, keybindings)) { - const browserWorkspaceId = resolveWorkspaceId?.(browserTabId) - if (browserWorkspaceId) { - const source: BrowserFindSource = { - browserPageId: browserTabId, - browserWorkspaceId - } - // Why: active browser splits share one renderer; preserve the registered guest owner so only its Find bar opens. - renderer.send('ui:findInBrowserPage', source) - } - } else if (keybindingMatchesAction('browser.back', input, process.platform, keybindings)) { - // Why: macOS Logitech side-button remaps arrive as history keystrokes, not mouse events; forward so the renderer can goBack(). - renderer.send('ui:browserHistoryNavigate', 'back') - } else if (keybindingMatchesAction('browser.forward', input, process.platform, keybindings)) { - // Why: same as browser.back; the focused guest cannot call the renderer-owned webview's goForward() directly. - renderer.send('ui:browserHistoryNavigate', 'forward') - } else if (keybindingMatchesAction('tab.close', input, process.platform, keybindings)) { - if (isFloatingGuest) { - renderer.send('ui:closeFloatingItem', { sourceId: browserTabId }) - } else { - renderer.send('ui:closeActiveTab') - } - } else if (keybindingMatchesAction('tab.nextSameType', input, process.platform, keybindings)) { - renderer.send('ui:switchTab', 1) - } else if ( - keybindingMatchesAction('tab.previousSameType', input, process.platform, keybindings) - ) { - renderer.send('ui:switchTab', -1) - } else if (action?.type === 'toggleWorktreePalette') { - renderer.send('ui:toggleWorktreePalette') - } else if (action?.type === 'openQuickOpen') { - renderer.send('ui:openQuickOpen') - } else if (action?.type === 'toggleQuickCommandsMenu') { - renderer.send('ui:toggleQuickCommandsMenu') - } else if (action?.type === 'openNewWorkspace') { - renderer.send('ui:openNewWorkspace') - } else if (action?.type === 'openWorkspaceBoard') { - renderer.send('ui:openWorkspaceBoard') - } else if (action?.type === 'openTasks') { - renderer.send('ui:openTasks') - } else if (action?.type === 'openSettings') { - renderer.send('ui:openSettings') - } else if (action?.type === 'forceReload') { - renderer.reloadIgnoringCache() - } else if (action?.type === 'jumpToWorktreeIndex') { - if (isFloatingGuest) { - renderer.send('ui:selectFloatingIndex', { index: action.index }) - } else { - renderer.send('ui:jumpToWorktreeIndex', action.index) - } - } else if (action?.type === 'jumpToTabIndex') { - if (isFloatingGuest) { - renderer.send('ui:selectFloatingIndex', { index: action.index }) - } else { - renderer.send('ui:jumpToTabIndex', action.index) - } - } else if (action?.type === 'dictationKeyDown') { - if (!shouldForwardDictationShortcut?.()) { - return false - } - renderer.send('ui:dictationKeyDown') - } else { - return false - } - // Why: preventDefault stops the guest page from also processing the chord (e.g. Cmd+T opening a browser-internal new-tab page). - event.preventDefault() - return true - } - - const handler = (event: Electron.Event, input: Electron.Input): void => { - const keybindings = getKeybindings?.() - if ( - input.type === 'keyDown' && - matchesRecentTabSwitcherChord(input, process.platform, keybindings) - ) { - // Why: held switcher commits on Control keyup; preventDefault on Tab - // keydown suppresses that keyup in Electron and strands the overlay. - ctrlTabSwitching = true - const renderer = resolveRenderer(browserTabId) - renderer?.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true }) - return - } - - if (ctrlTabSwitching && isRecentTabSwitcherCommitRelease(input)) { - event.preventDefault() - ctrlTabSwitching = false - const renderer = resolveRenderer(browserTabId) - renderer?.send('ui:ctrlTabKeyUp') - return - } - - if (input.type === 'keyDown' || input.type === 'keyUp') { - const detected = doubleTapDetector.process( - toModifierDoubleTapEvent({ - type: input.type, - code: input.code, - key: input.key, - shift: input.shift, - control: input.control, - alt: input.alt, - meta: input.meta, - isAutoRepeat: input.isAutoRepeat - }), - Date.now() - ) - if (detected) { - const doubleTapInput: GuestShortcutInput = { doubleTapModifier: detected.modifier } - forwardShortcutInput( - event, - doubleTapInput, - resolveWindowShortcutAction(doubleTapInput, process.platform, keybindings, { - context: 'app' - }) - ) - return - } - } - - if (input.type !== 'keyDown') { - return - } - // Why: Cmd/Ctrl+Alt+Arrow is the only allowlisted chord carrying Alt, so resolve it before the Alt-rejecting chord gate below. - const action = resolveWindowShortcutAction(input, process.platform, keybindings) - forwardShortcutInput(event, input, action) - } - - const zoomCommandHandler = ( - event: Electron.Event, - zoomDirection: 'in' | 'out' | 'reset' - ): void => { - if (zoomDirection !== 'in' && zoomDirection !== 'out') { - return - } - // Why: some layouts/platforms turn Ctrl/Cmd +/- into Electron's native zoom before before-input-event reaches the guest. - if (consumeRecentGuestWheelZoom(guest, zoomDirection)) { - event.preventDefault() - return - } - if (!nativeZoomCommandMatchesKeybindings(zoomDirection, process.platform, getKeybindings?.())) { - return - } - forwardBrowserPageZoom(event, zoomDirection) - } - - guest.on('before-input-event', handler) - guest.on('zoom-changed', zoomCommandHandler) - guest.on('blur', resetDoubleTapDetector) - return () => { - try { - guest.off('before-input-event', handler) - guest.off('zoom-changed', zoomCommandHandler) - guest.off('blur', resetDoubleTapDetector) - } catch { - // Why: best-effort — guest may already be destroyed during teardown. - } - } -} - -export function setupGuestMouseWheelZoomForwarding(args: { - browserTabId: string - guest: Electron.WebContents - resolveRenderer: ResolveRenderer -}): () => void { - const { browserTabId, guest, resolveRenderer } = args - const handler = (event: Electron.Event, mouse: Electron.MouseInputEvent): void => { - const direction = resolveGuestMouseWheelZoomDirection(mouse) - if (!direction) { - return - } - // Why: wheel input over a focused webview never reaches renderer DOM handlers, so consume and forward here. - event.preventDefault() - markGuestWheelZoom(guest, direction) - resolveRenderer(browserTabId)?.send('ui:zoomBrowserPage', direction) - } - - guest.on('before-mouse-event', handler) - return () => { - try { - guest.off('before-mouse-event', handler) - } catch { - // Why: best-effort — guest may already be destroyed during teardown. - } - } -} - -export function resolveRendererWebContents( - rendererWebContentsIdByTabId: ReadonlyMap, - browserTabId: string -): Electron.WebContents | null { - const rendererWcId = rendererWebContentsIdByTabId.get(browserTabId) - if (!rendererWcId) { - return null - } - const renderer = webContents.fromId(rendererWcId) - if (!renderer || renderer.isDestroyed()) { - return null - } - return renderer -} diff --git a/src/main/browser/browser-guest-wheel-zoom.ts b/src/main/browser/browser-guest-wheel-zoom.ts new file mode 100644 index 00000000000..40b884ee4c2 --- /dev/null +++ b/src/main/browser/browser-guest-wheel-zoom.ts @@ -0,0 +1,92 @@ +import type { BrowserPageZoomDirection } from '../../shared/browser-page-zoom' +import type { ResolveRenderer } from './browser-guest-renderer-target' + +const CONTROL_MODIFIERS = new Set(['control', 'ctrl']) +const MAC_COMMAND_MODIFIERS = new Set(['meta', 'command', 'cmd']) +const WHEEL_ZOOM_BLOCKING_MODIFIERS = new Set(['alt', 'shift']) +const GUEST_WHEEL_ZOOM_DEDUPE_MS = 250 + +export type GuestWheelZoomDirection = Exclude + +const recentGuestWheelZoomByGuest = new WeakMap< + Electron.WebContents, + { direction: GuestWheelZoomDirection; at: number } +>() + +function markGuestWheelZoom(guest: Electron.WebContents, direction: GuestWheelZoomDirection): void { + recentGuestWheelZoomByGuest.set(guest, { direction, at: Date.now() }) +} + +export function consumeRecentGuestWheelZoom( + guest: Electron.WebContents, + direction: GuestWheelZoomDirection +): boolean { + const recent = recentGuestWheelZoomByGuest.get(guest) + if (!recent) { + return false + } + const elapsed = Date.now() - recent.at + if (elapsed < 0 || elapsed > GUEST_WHEEL_ZOOM_DEDUPE_MS) { + recentGuestWheelZoomByGuest.delete(guest) + return false + } + if (recent.direction !== direction) { + return false + } + recentGuestWheelZoomByGuest.delete(guest) + return true +} + +function hasModifier(mouse: Electron.MouseInputEvent, modifiers: ReadonlySet): boolean { + return mouse.modifiers?.some((modifier) => modifiers.has(modifier)) ?? false +} + +export function resolveGuestMouseWheelZoomDirection( + mouse: Electron.MouseInputEvent, + platform: NodeJS.Platform = process.platform +): GuestWheelZoomDirection | null { + if (mouse.type !== 'mouseWheel') { + return null + } + if (hasModifier(mouse, WHEEL_ZOOM_BLOCKING_MODIFIERS)) { + return null + } + const hasZoomModifier = + hasModifier(mouse, CONTROL_MODIFIERS) || + (platform === 'darwin' && hasModifier(mouse, MAC_COMMAND_MODIFIERS)) + if (!hasZoomModifier) { + return null + } + const deltaY = (mouse as Electron.MouseWheelInputEvent).deltaY + if (typeof deltaY !== 'number' || deltaY === 0) { + return null + } + return deltaY < 0 ? 'in' : 'out' +} + +export function setupGuestMouseWheelZoomForwarding(args: { + browserTabId: string + guest: Electron.WebContents + resolveRenderer: ResolveRenderer +}): () => void { + const { browserTabId, guest, resolveRenderer } = args + const handler = (event: Electron.Event, mouse: Electron.MouseInputEvent): void => { + const direction = resolveGuestMouseWheelZoomDirection(mouse) + if (!direction) { + return + } + // Why: wheel input over a focused webview never reaches renderer DOM handlers, so consume and forward here. + event.preventDefault() + markGuestWheelZoom(guest, direction) + resolveRenderer(browserTabId)?.send('ui:zoomBrowserPage', direction) + } + + guest.on('before-mouse-event', handler) + return () => { + try { + guest.off('before-mouse-event', handler) + } catch { + // Why: best-effort — guest may already be destroyed during teardown. + } + } +} diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 0b10e9e189c..3af2e46be1c 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -28,13 +28,11 @@ import { clampGrabPayload } from './browser-grab-payload' import { captureSelectionScreenshot as captureGrabSelectionScreenshot } from './browser-grab-screenshot' import { BrowserGrabSessionController } from './browser-grab-session-controller' import { browserDownloadDestinationReservations } from './browser-download-destination' -import { - resolveRendererWebContents, - setupGrabShortcutForwarding, - setupGuestContextMenu, - setupGuestMouseWheelZoomForwarding, - setupGuestShortcutForwarding -} from './browser-guest-ui' +import { resolveRendererWebContents } from './browser-guest-renderer-target' +import { setupGrabShortcutForwarding } from './browser-guest-grab-shortcuts' +import { setupGuestContextMenu } from './browser-guest-context-menu' +import { setupGuestMouseWheelZoomForwarding } from './browser-guest-wheel-zoom' +import { setupGuestShortcutForwarding } from './browser-guest-shortcut-forwarding' import { ANTI_DETECTION_SCRIPT } from './anti-detection' import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' import { diff --git a/src/main/browser/browser-screencast-cdp-events.ts b/src/main/browser/browser-screencast-cdp-events.ts new file mode 100644 index 00000000000..7381c1257ed --- /dev/null +++ b/src/main/browser/browser-screencast-cdp-events.ts @@ -0,0 +1,103 @@ +import { Buffer } from 'node:buffer' +import type { WebContents } from 'electron' +import { sendDebuggerCommand } from './browser-screencast-debugger-command' +import { enrichFrameMetadata, readFrameMetadata } from './browser-screencast-frame-metadata' +import { readBrowserScreencastImageSize } from './browser-screencast-image-size' +import type { + BrowserScreencastOptions, + PendingScreencastFrame +} from './browser-screencast-stream-types' +import { isLiveFrameCompatibleWithViewport } from './browser-screencast-viewport-fit' + +type BrowserScreencastMessageHandlerDeps = { + dbg: WebContents['debugger'] + options: BrowserScreencastOptions + isClosed: () => boolean + isStopping: () => boolean + queueFrame: (frame: PendingScreencastFrame) => void + ackScreencastFrame: (sessionId: number | undefined) => void + scheduleNavigationFrameCapture: () => void + clearNavigationCaptureTimer: () => void + bumpSnapshotGeneration: () => void +} + +export function createBrowserScreencastMessageHandler( + deps: BrowserScreencastMessageHandlerDeps +): (event: unknown, method: string, params: unknown) => void { + const { dbg, options, isClosed, isStopping, queueFrame, ackScreencastFrame } = deps + const { scheduleNavigationFrameCapture, clearNavigationCaptureTimer, bumpSnapshotGeneration } = + deps + + return (_event: unknown, method: string, params: unknown): void => { + if (isClosed()) { + return + } + if (isStopping() && method !== 'Page.screencastFrame') { + return + } + if (method === 'Page.javascriptDialogOpening') { + const payload = + params && typeof params === 'object' ? (params as Record) : {} + options.onEvent?.({ + type: 'dialog', + dialogType: typeof payload.type === 'string' ? payload.type : 'alert', + message: typeof payload.message === 'string' ? payload.message : 'Browser dialog' + }) + return + } + if (method === 'Page.javascriptDialogClosed') { + options.onEvent?.({ type: 'dialogClosed' }) + return + } + if (method === 'Page.frameNavigated') { + const payload = + params && typeof params === 'object' ? (params as Record) : {} + const frame = payload.frame && typeof payload.frame === 'object' ? payload.frame : null + if (!frame || !('parentId' in frame)) { + scheduleNavigationFrameCapture() + } + return + } + if (method === 'Page.loadEventFired') { + scheduleNavigationFrameCapture() + return + } + if (method !== 'Page.screencastFrame') { + return + } + const payload = params && typeof params === 'object' ? (params as Record) : {} + const data = typeof payload.data === 'string' ? payload.data : null + const sessionId = typeof payload.sessionId === 'number' ? payload.sessionId : null + if (!data || sessionId === null) { + return + } + if (isStopping()) { + void sendDebuggerCommand(dbg, 'Page.screencastFrameAck', { sessionId }).catch(() => {}) + return + } + + try { + const image = new Uint8Array(Buffer.from(data, 'base64')) + // Why: image dimension parsing happens for every live frame; share the + // result between stale-frame rejection and metadata enrichment. + const imageSize = readBrowserScreencastImageSize(image, options.format) + if (!isLiveFrameCompatibleWithViewport(imageSize, options)) { + // Why: after tab switches/navigation Chromium can briefly stream the + // host surface instead of the requested client viewport. Dropping that + // frame keeps the client from rendering server-sized blank gutters. + ackScreencastFrame(sessionId) + scheduleNavigationFrameCapture() + return + } + bumpSnapshotGeneration() + clearNavigationCaptureTimer() + queueFrame({ + metadata: enrichFrameMetadata(readFrameMetadata(payload.metadata), imageSize, options), + image, + sessionId + }) + } catch { + ackScreencastFrame(sessionId) + } + } +} diff --git a/src/main/browser/browser-screencast-debugger-command.ts b/src/main/browser/browser-screencast-debugger-command.ts new file mode 100644 index 00000000000..3a607e416ec --- /dev/null +++ b/src/main/browser/browser-screencast-debugger-command.ts @@ -0,0 +1,25 @@ +import type { WebContents } from 'electron' + +const DEBUGGER_COMMAND_TIMEOUT_MS = 8_000 + +export async function sendDebuggerCommand( + dbg: WebContents['debugger'], + method: string, + params: Record = {} +): Promise { + let timeout: ReturnType | null = null + try { + return await Promise.race([ + Promise.resolve().then(() => dbg.sendCommand(method, params)), + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(`Timed out while running ${method}.`)) + }, DEBUGGER_COMMAND_TIMEOUT_MS) + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} diff --git a/src/main/browser/browser-screencast-device-metrics.ts b/src/main/browser/browser-screencast-device-metrics.ts new file mode 100644 index 00000000000..0d55b24614c --- /dev/null +++ b/src/main/browser/browser-screencast-device-metrics.ts @@ -0,0 +1,56 @@ +import type { Debugger, WebContents } from 'electron' +import { sendDebuggerCommand } from './browser-screencast-debugger-command' +import type { BrowserScreencastOptions } from './browser-screencast-stream-types' +import { positiveInteger, positiveNumber } from './browser-screencast-viewport-fit' + +export type BrowserScreencastDeviceMetrics = { + apply: () => Promise + clear: () => Promise + isOverridden: () => boolean +} + +export function createBrowserScreencastDeviceMetrics( + webContents: WebContents, + dbg: Debugger, + options: BrowserScreencastOptions +): BrowserScreencastDeviceMetrics { + let deviceMetricsOverridden = false + + const clearDeviceMetricsOverride = async (): Promise => { + if (webContents.isDestroyed() || !dbg.isAttached()) { + deviceMetricsOverridden = false + return + } + await sendDebuggerCommand(dbg, 'Emulation.clearDeviceMetricsOverride') + deviceMetricsOverridden = false + } + + const applyDeviceMetricsOverride = async (): Promise => { + const viewportWidth = positiveInteger(options.viewportWidth) + const viewportHeight = positiveInteger(options.viewportHeight) + if (!viewportWidth || !viewportHeight) { + return + } + const deviceScaleFactor = positiveNumber(options.deviceScaleFactor) ?? 1 + // Why: Back/Forward and cross-process navigations can drop emulation while + // the screencast remains attached. Reapply before fallback captures so the + // page lays out at the client pane size, not the host BrowserView size. + await sendDebuggerCommand(dbg, 'Emulation.setDeviceMetricsOverride', { + width: viewportWidth, + height: viewportHeight, + deviceScaleFactor, + mobile: options.mobile === true + }) + await sendDebuggerCommand(dbg, 'Emulation.setVisibleSize', { + width: viewportWidth, + height: viewportHeight + }).catch(() => {}) + deviceMetricsOverridden = true + } + + return { + apply: applyDeviceMetricsOverride, + clear: clearDeviceMetricsOverride, + isOverridden: () => deviceMetricsOverridden + } +} diff --git a/src/main/browser/browser-screencast-frame-metadata.ts b/src/main/browser/browser-screencast-frame-metadata.ts new file mode 100644 index 00000000000..abc0ba06f9e --- /dev/null +++ b/src/main/browser/browser-screencast-frame-metadata.ts @@ -0,0 +1,70 @@ +import type { BrowserScreencastFrameMetadata } from '../../shared/browser-screencast-protocol' +import type { + BrowserScreencastOptions, + ScreencastImageSize +} from './browser-screencast-stream-types' +import { positiveInteger } from './browser-screencast-viewport-fit' + +function finiteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +export function readFrameMetadata(raw: unknown): BrowserScreencastFrameMetadata { + const metadata = raw && typeof raw === 'object' ? (raw as Record) : {} + return { + offsetTop: finiteNumber(metadata.offsetTop), + pageScaleFactor: finiteNumber(metadata.pageScaleFactor), + deviceWidth: finiteNumber(metadata.deviceWidth), + deviceHeight: finiteNumber(metadata.deviceHeight), + imageWidth: finiteNumber(metadata.imageWidth), + imageHeight: finiteNumber(metadata.imageHeight), + scrollOffsetX: finiteNumber(metadata.scrollOffsetX), + scrollOffsetY: finiteNumber(metadata.scrollOffsetY), + timestamp: finiteNumber(metadata.timestamp) + } +} + +function selectFrameDeviceSize( + reportedSize: number | undefined, + requestedCssSize: number | null, + imageSize: number | undefined +): number | undefined { + if (requestedCssSize) { + // Why: paired clients own the remote browser viewport. If Chromium briefly + // reports the host BrowserView size, publishing that size makes the client + // compensate with crop/contain math and exposes blank compositor space. + return requestedCssSize + } + return reportedSize ?? imageSize +} + +export function enrichFrameMetadata( + metadata: BrowserScreencastFrameMetadata, + imageSize: ScreencastImageSize | null, + options: BrowserScreencastOptions +): BrowserScreencastFrameMetadata { + const viewportWidth = positiveInteger(options.viewportWidth) + const viewportHeight = positiveInteger(options.viewportHeight) + const enriched: BrowserScreencastFrameMetadata = { ...metadata } + const deviceWidth = selectFrameDeviceSize(enriched.deviceWidth, viewportWidth, imageSize?.width) + const deviceHeight = selectFrameDeviceSize( + enriched.deviceHeight, + viewportHeight, + imageSize?.height + ) + const imageWidth = imageSize?.width ?? enriched.imageWidth + const imageHeight = imageSize?.height ?? enriched.imageHeight + if (deviceWidth !== undefined) { + enriched.deviceWidth = deviceWidth + } + if (deviceHeight !== undefined) { + enriched.deviceHeight = deviceHeight + } + if (imageWidth !== undefined) { + enriched.imageWidth = imageWidth + } + if (imageHeight !== undefined) { + enriched.imageHeight = imageHeight + } + return enriched +} diff --git a/src/main/browser/browser-screencast-frame-pacer.ts b/src/main/browser/browser-screencast-frame-pacer.ts new file mode 100644 index 00000000000..c313de0fb3c --- /dev/null +++ b/src/main/browser/browser-screencast-frame-pacer.ts @@ -0,0 +1,153 @@ +import type { WebContents } from 'electron' +import { + BrowserScreencastOpcode, + encodeBrowserScreencastFrame +} from '../../shared/browser-screencast-protocol' +import { sendDebuggerCommand } from './browser-screencast-debugger-command' +import type { + BrowserScreencastOptions, + PendingScreencastFrame +} from './browser-screencast-stream-types' + +const BACKPRESSURE_RETRY_MS = 50 + +type BrowserScreencastFramePacerDeps = { + dbg: WebContents['debugger'] + options: BrowserScreencastOptions + isClosed: () => boolean + isStopping: () => boolean +} + +export type BrowserScreencastFramePacer = { + queueFrame: (frame: PendingScreencastFrame) => void + ackFrame: (sessionId: number | undefined) => void + clearPending: (ackPending?: boolean) => void + getSeq: () => number +} + +export function createBrowserScreencastFramePacer( + deps: BrowserScreencastFramePacerDeps +): BrowserScreencastFramePacer { + const { dbg, options, isClosed, isStopping } = deps + let seq = 0 + let lastFrameSentAt = 0 + let pendingFrame: PendingScreencastFrame | null = null + let pendingFrameTimer: ReturnType | null = null + + const ackScreencastFrame = (sessionId: number | undefined): void => { + if (sessionId === undefined) { + return + } + // Why: CDP only sends the next frame after ACK; delaying ACK for + // throttled frames applies back-pressure before Chromium/base64 work piles up. + void sendDebuggerCommand(dbg, 'Page.screencastFrameAck', { sessionId }).catch(() => {}) + } + + const clearPendingFrameTimer = (ackPending = false): void => { + const pending = pendingFrame + pendingFrame = null + if (pendingFrameTimer) { + clearTimeout(pendingFrameTimer) + pendingFrameTimer = null + } + if (ackPending) { + ackScreencastFrame(pending?.sessionId) + } + } + + const emitFrame = (frame: PendingScreencastFrame): boolean => { + if (isClosed() || isStopping()) { + return false + } + lastFrameSentAt = Date.now() + const accepted = options.onFrame( + encodeBrowserScreencastFrame({ + opcode: BrowserScreencastOpcode.Frame, + seq: seq++, + format: options.format, + // Why: Chromium sometimes omits device dimensions on static/mobile + // pages; carrying viewport/image dimensions prevents client stretch. + metadata: frame.metadata, + image: frame.image + }) + ) + return accepted !== false + } + + const schedulePendingFrameRetry = (): void => { + if (pendingFrameTimer || isClosed() || isStopping()) { + return + } + pendingFrameTimer = setTimeout(() => { + pendingFrameTimer = null + const latest = pendingFrame + pendingFrame = null + if (isClosed() || isStopping() || !latest) { + return + } + if (emitFrame(latest)) { + ackScreencastFrame(latest.sessionId) + } else { + pendingFrame = latest + schedulePendingFrameRetry() + } + }, BACKPRESSURE_RETRY_MS) + } + + const queueFrame = (frame: PendingScreencastFrame): void => { + if (isClosed() || isStopping()) { + return + } + const now = Date.now() + const elapsed = now - lastFrameSentAt + if ( + options.minFrameIntervalMs <= 0 || + lastFrameSentAt === 0 || + elapsed >= options.minFrameIntervalMs + ) { + clearPendingFrameTimer(true) + if (emitFrame(frame)) { + ackScreencastFrame(frame.sessionId) + } else { + pendingFrame = frame + schedulePendingFrameRetry() + } + return + } + + // Why: static UI changes can be the last frame Chromium emits. Keep the + // newest throttled frame and flush it after the interval instead of + // dropping it forever. + if (pendingFrame?.sessionId !== frame.sessionId) { + ackScreencastFrame(pendingFrame?.sessionId) + } + pendingFrame = frame + if (pendingFrameTimer) { + return + } + pendingFrameTimer = setTimeout( + () => { + pendingFrameTimer = null + const latest = pendingFrame + pendingFrame = null + if (isClosed() || isStopping() || !latest) { + return + } + if (emitFrame(latest)) { + ackScreencastFrame(latest.sessionId) + } else { + pendingFrame = latest + schedulePendingFrameRetry() + } + }, + Math.max(0, options.minFrameIntervalMs - elapsed) + ) + } + + return { + queueFrame, + ackFrame: ackScreencastFrame, + clearPending: clearPendingFrameTimer, + getSeq: () => seq + } +} diff --git a/src/main/browser/browser-screencast-snapshot-capture.ts b/src/main/browser/browser-screencast-snapshot-capture.ts new file mode 100644 index 00000000000..b06ce8bb5cf --- /dev/null +++ b/src/main/browser/browser-screencast-snapshot-capture.ts @@ -0,0 +1,162 @@ +import { Buffer } from 'node:buffer' +import type { Debugger, WebContents } from 'electron' +import { readBrowserScreencastImageSize } from './browser-screencast-image-size' +import { sendDebuggerCommand } from './browser-screencast-debugger-command' +import type { + BrowserScreencastOptions, + PendingScreencastFrame +} from './browser-screencast-stream-types' +import { positiveInteger, scaleSnapshotToFit } from './browser-screencast-viewport-fit' + +type BrowserScreencastSnapshotCaptureDeps = { + webContents: WebContents + dbg: Debugger + options: BrowserScreencastOptions + isClosed: () => boolean + isStopping: () => boolean + getSeq: () => number + queueFrame: (frame: PendingScreencastFrame) => void + applyDeviceMetricsOverride: () => Promise +} + +export type BrowserScreencastSnapshotCapture = { + emitSnapshotFrame: (initialOnly: boolean, generation?: number) => Promise + scheduleNavigationFrameCapture: () => void + clearNavigationCaptureTimer: () => void + bumpGeneration: () => void +} + +export function createBrowserScreencastSnapshotCapture( + deps: BrowserScreencastSnapshotCaptureDeps +): BrowserScreencastSnapshotCapture { + const { webContents, dbg, options, isClosed, isStopping, getSeq, queueFrame } = deps + const { applyDeviceMetricsOverride } = deps + + let snapshotGeneration = 0 + let navigationCaptureTimer: ReturnType | null = null + + const clearNavigationCaptureTimer = (): void => { + if (navigationCaptureTimer) { + clearTimeout(navigationCaptureTimer) + navigationCaptureTimer = null + } + } + + const scheduleNavigationFrameCapture = (): void => { + if (isClosed() || isStopping()) { + return + } + clearNavigationCaptureTimer() + const generation = ++snapshotGeneration + // Why: static pages can finish navigation without producing a live + // screencast frame, leaving mobile on the previous page image. + navigationCaptureTimer = setTimeout(() => { + navigationCaptureTimer = null + void emitSnapshotFrame(false, generation) + }, 250) + } + + const isSnapshotStale = (initialOnly: boolean, generation?: number): boolean => + isClosed() || + isStopping() || + (initialOnly && getSeq() > 0) || + (generation !== undefined && generation !== snapshotGeneration) + + const emitSnapshotFrame = async (initialOnly: boolean, generation?: number): Promise => { + if (isSnapshotStale(initialOnly, generation)) { + return + } + try { + const viewportWidth = positiveInteger(options.viewportWidth) + const viewportHeight = positiveInteger(options.viewportHeight) + let image: Uint8Array | null = null + await applyDeviceMetricsOverride() + if (isSnapshotStale(initialOnly, generation)) { + return + } + if (viewportWidth && viewportHeight && typeof webContents.capturePage === 'function') { + try { + // Why: CDP captureScreenshot can tile BrowserView surfaces under + // mobile emulation; Electron captures the actual visible viewport. + const nativeImage = await webContents.capturePage({ + x: 0, + y: 0, + width: viewportWidth, + height: viewportHeight + }) + const capture = scaleSnapshotToFit(nativeImage, options) + const buffer = + options.format === 'png' ? capture.toPNG() : capture.toJPEG(options.quality) + if (buffer.byteLength > 0) { + image = new Uint8Array(buffer) + } + } catch { + image = null + } + } + // Why: Page.startScreencast may not produce a frame for an already-painted + // blank/static page, which leaves remote browser clients showing only the shell. + if (!image) { + const result = await sendDebuggerCommand(dbg, 'Page.captureScreenshot', { + format: options.format, + ...(options.format === 'jpeg' ? { quality: options.quality } : {}), + ...(viewportWidth && viewportHeight + ? { + // Why: mobile emulation + DPR can make Chromium capture a larger + // surface than the visual viewport. Clipping keeps fallback frames + // in the same coordinate space as live screencast frames. + clip: { + x: 0, + y: 0, + width: viewportWidth, + height: viewportHeight, + scale: 1 + } + } + : {}), + captureBeyondViewport: false + }) + if (isSnapshotStale(initialOnly, generation)) { + return + } + const payload = + result && typeof result === 'object' ? (result as Record) : {} + const data = typeof payload.data === 'string' ? payload.data : null + if (!data) { + return + } + image = new Uint8Array(Buffer.from(data, 'base64')) + } + if (isSnapshotStale(initialOnly, generation)) { + return + } + const imageSize = readBrowserScreencastImageSize(image, options.format) + const baseMetadata = + viewportWidth && viewportHeight + ? { deviceWidth: viewportWidth, deviceHeight: viewportHeight } + : imageSize + ? { deviceWidth: imageSize.width, deviceHeight: imageSize.height } + : {} + queueFrame({ + // Why: static pages may only produce this fallback capture. Without + // dimensions, mobile clients stretch it to the phone aspect ratio. + metadata: { + ...baseMetadata, + ...(imageSize ? { imageWidth: imageSize.width, imageHeight: imageSize.height } : {}) + }, + image + }) + } catch { + // Best effort only: live Page.screencastFrame events still drive the stream. + } + } + + return { + emitSnapshotFrame, + scheduleNavigationFrameCapture, + clearNavigationCaptureTimer, + bumpGeneration: () => { + snapshotGeneration += 1 + } + } +} diff --git a/src/main/browser/browser-screencast-stream-types.ts b/src/main/browser/browser-screencast-stream-types.ts new file mode 100644 index 00000000000..98e4f46fbcd --- /dev/null +++ b/src/main/browser/browser-screencast-stream-types.ts @@ -0,0 +1,37 @@ +import type { + BrowserScreencastFormat, + BrowserScreencastFrameMetadata +} from '../../shared/browser-screencast-protocol' + +export type BrowserScreencastOptions = { + format: BrowserScreencastFormat + quality: number + maxWidth: number + maxHeight: number + viewportWidth?: number + viewportHeight?: number + deviceScaleFactor?: number + mobile?: boolean + everyNthFrame: number + minFrameIntervalMs: number + onFrame: (bytes: Uint8Array) => boolean | void + onEvent?: (event: BrowserScreencastEvent) => void + onError?: (message: string) => void +} + +export type BrowserScreencastSession = { stop: () => void; done: Promise } + +export type BrowserScreencastEvent = + | { type: 'dialog'; dialogType: string; message: string } + | { type: 'dialogClosed' } + +export type PendingScreencastFrame = { + metadata: BrowserScreencastFrameMetadata + image: Uint8Array + sessionId?: number +} + +export type ScreencastImageSize = { + width: number + height: number +} diff --git a/src/main/browser/browser-screencast-stream.ts b/src/main/browser/browser-screencast-stream.ts index 15a5395cef8..280e861b539 100644 --- a/src/main/browser/browser-screencast-stream.ts +++ b/src/main/browser/browser-screencast-stream.ts @@ -1,220 +1,15 @@ -/* eslint-disable max-lines -- Why: screencast setup, CDP lifecycle, metadata normalization, and stream teardown stay together so frame behavior cannot drift across files. */ -import { Buffer } from 'node:buffer' -import type { NativeImage, WebContents } from 'electron' -import { - BrowserScreencastOpcode, - encodeBrowserScreencastFrame, - type BrowserScreencastFormat, - type BrowserScreencastFrameMetadata -} from '../../shared/browser-screencast-protocol' +import type { WebContents } from 'electron' import { BrowserError } from './cdp-bridge' import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease' -import { readBrowserScreencastImageSize } from './browser-screencast-image-size' - -const DEBUGGER_COMMAND_TIMEOUT_MS = 8_000 -const BACKPRESSURE_RETRY_MS = 50 - -export type BrowserScreencastOptions = { - format: BrowserScreencastFormat - quality: number - maxWidth: number - maxHeight: number - viewportWidth?: number - viewportHeight?: number - deviceScaleFactor?: number - mobile?: boolean - everyNthFrame: number - minFrameIntervalMs: number - onFrame: (bytes: Uint8Array) => boolean | void - onEvent?: (event: BrowserScreencastEvent) => void - onError?: (message: string) => void -} - -export type BrowserScreencastSession = { stop: () => void; done: Promise } - -type BrowserScreencastEvent = - | { type: 'dialog'; dialogType: string; message: string } - | { type: 'dialogClosed' } - -type PendingScreencastFrame = { - metadata: BrowserScreencastFrameMetadata - image: Uint8Array - sessionId?: number -} - -type ScreencastImageSize = { - width: number - height: number -} - -function finiteNumber(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined -} - -function readFrameMetadata(raw: unknown): BrowserScreencastFrameMetadata { - const metadata = raw && typeof raw === 'object' ? (raw as Record) : {} - return { - offsetTop: finiteNumber(metadata.offsetTop), - pageScaleFactor: finiteNumber(metadata.pageScaleFactor), - deviceWidth: finiteNumber(metadata.deviceWidth), - deviceHeight: finiteNumber(metadata.deviceHeight), - imageWidth: finiteNumber(metadata.imageWidth), - imageHeight: finiteNumber(metadata.imageHeight), - scrollOffsetX: finiteNumber(metadata.scrollOffsetX), - scrollOffsetY: finiteNumber(metadata.scrollOffsetY), - timestamp: finiteNumber(metadata.timestamp) - } -} - -function isNear(value: number, expected: number): boolean { - return Math.abs(value - expected) <= Math.max(2, expected * 0.02) -} - -function scaleToFit( - width: number, - height: number, - maxWidth: number, - maxHeight: number -): { - width: number - height: number -} { - const scale = Math.min(1, maxWidth / width, maxHeight / height) - return { - width: Math.round(width * scale), - height: Math.round(height * scale) - } -} - -// Why: capturePage returns device pixels, so a hi-DPI viewport yields a bitmap several times -// larger than the live path is allowed to send. Apply the caller's cap here too. -function scaleSnapshotToFit(image: NativeImage, options: BrowserScreencastOptions): NativeImage { - const size = image.getSize() - if (!size.width || !size.height) { - return image - } - const fitted = scaleToFit(size.width, size.height, options.maxWidth, options.maxHeight) - if (fitted.width === size.width && fitted.height === size.height) { - return image - } - return image.resize(fitted) -} - -function isNearSize( - actual: { width: number; height: number }, - expected: { width: number; height: number } -): boolean { - return isNear(actual.width, expected.width) && isNear(actual.height, expected.height) -} - -function selectFrameDeviceSize( - reportedSize: number | undefined, - requestedCssSize: number | null, - imageSize: number | undefined -): number | undefined { - if (requestedCssSize) { - // Why: paired clients own the remote browser viewport. If Chromium briefly - // reports the host BrowserView size, publishing that size makes the client - // compensate with crop/contain math and exposes blank compositor space. - return requestedCssSize - } - return reportedSize ?? imageSize -} - -function isLiveFrameCompatibleWithViewport( - imageSize: ScreencastImageSize | null, - options: BrowserScreencastOptions -): boolean { - const viewportWidth = positiveInteger(options.viewportWidth) - const viewportHeight = positiveInteger(options.viewportHeight) - if (!viewportWidth || !viewportHeight) { - return true - } - if (!imageSize) { - return true - } - const deviceScaleFactor = positiveNumber(options.deviceScaleFactor) ?? 1 - const cssViewport = { width: viewportWidth, height: viewportHeight } - const deviceViewport = { - width: Math.round(viewportWidth * deviceScaleFactor), - height: Math.round(viewportHeight * deviceScaleFactor) - } - const scaledDeviceViewport = scaleToFit( - deviceViewport.width, - deviceViewport.height, - options.maxWidth, - options.maxHeight - ) - // Why: Chromium can stream CSS-sized, DPR-sized, or maxWidth/maxHeight-scaled - // bitmaps for the same emulated viewport. All are client-authoritative; stale - // host BrowserView frames are the incompatible ones we need to drop. - return ( - isNearSize(imageSize, cssViewport) || - isNearSize(imageSize, deviceViewport) || - isNearSize(imageSize, scaledDeviceViewport) - ) -} - -function enrichFrameMetadata( - metadata: BrowserScreencastFrameMetadata, - imageSize: ScreencastImageSize | null, - options: BrowserScreencastOptions -): BrowserScreencastFrameMetadata { - const viewportWidth = positiveInteger(options.viewportWidth) - const viewportHeight = positiveInteger(options.viewportHeight) - const enriched: BrowserScreencastFrameMetadata = { ...metadata } - const deviceWidth = selectFrameDeviceSize(enriched.deviceWidth, viewportWidth, imageSize?.width) - const deviceHeight = selectFrameDeviceSize( - enriched.deviceHeight, - viewportHeight, - imageSize?.height - ) - const imageWidth = imageSize?.width ?? enriched.imageWidth - const imageHeight = imageSize?.height ?? enriched.imageHeight - if (deviceWidth !== undefined) { - enriched.deviceWidth = deviceWidth - } - if (deviceHeight !== undefined) { - enriched.deviceHeight = deviceHeight - } - if (imageWidth !== undefined) { - enriched.imageWidth = imageWidth - } - if (imageHeight !== undefined) { - enriched.imageHeight = imageHeight - } - return enriched -} - -function positiveInteger(value: number | undefined): number | null { - return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : null -} - -function positiveNumber(value: number | undefined): number | null { - return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null -} - -async function sendDebuggerCommand( - dbg: WebContents['debugger'], - method: string, - params: Record = {} -): Promise { - let timeout: ReturnType | null = null - try { - return await Promise.race([ - Promise.resolve().then(() => dbg.sendCommand(method, params)), - new Promise((_, reject) => { - timeout = setTimeout(() => { - reject(new Error(`Timed out while running ${method}.`)) - }, DEBUGGER_COMMAND_TIMEOUT_MS) - }) - ]) - } finally { - if (timeout) { - clearTimeout(timeout) - } - } -} +import { createBrowserScreencastMessageHandler } from './browser-screencast-cdp-events' +import { sendDebuggerCommand } from './browser-screencast-debugger-command' +import { createBrowserScreencastDeviceMetrics } from './browser-screencast-device-metrics' +import { createBrowserScreencastFramePacer } from './browser-screencast-frame-pacer' +import { createBrowserScreencastSnapshotCapture } from './browser-screencast-snapshot-capture' +import type { + BrowserScreencastOptions, + BrowserScreencastSession +} from './browser-screencast-stream-types' export async function startBrowserScreencast( webContents: WebContents, @@ -237,174 +32,44 @@ export async function startBrowserScreencast( let closed = false let stopping = false - let seq = 0 - let lastFrameSentAt = 0 - let deviceMetricsOverridden = false - let snapshotGeneration = 0 - let navigationCaptureTimer: ReturnType | null = null - let pendingFrame: PendingScreencastFrame | null = null - let pendingFrameTimer: ReturnType | null = null let resolveDone!: () => void const done = new Promise((resolve) => { resolveDone = resolve }) + const isClosed = (): boolean => closed + const isStopping = (): boolean => stopping - const clearNavigationCaptureTimer = (): void => { - if (navigationCaptureTimer) { - clearTimeout(navigationCaptureTimer) - navigationCaptureTimer = null - } - } - - const ackScreencastFrame = (sessionId: number | undefined): void => { - if (sessionId === undefined) { - return - } - // Why: CDP only sends the next frame after ACK; delaying ACK for - // throttled frames applies back-pressure before Chromium/base64 work piles up. - void sendDebuggerCommand(dbg, 'Page.screencastFrameAck', { sessionId }).catch(() => {}) - } - - const clearPendingFrameTimer = (ackPending = false): void => { - const pending = pendingFrame - pendingFrame = null - if (pendingFrameTimer) { - clearTimeout(pendingFrameTimer) - pendingFrameTimer = null - } - if (ackPending) { - ackScreencastFrame(pending?.sessionId) - } - } - - const emitFrame = (frame: PendingScreencastFrame): boolean => { - if (closed || stopping) { - return false - } - lastFrameSentAt = Date.now() - const accepted = options.onFrame( - encodeBrowserScreencastFrame({ - opcode: BrowserScreencastOpcode.Frame, - seq: seq++, - format: options.format, - // Why: Chromium sometimes omits device dimensions on static/mobile - // pages; carrying viewport/image dimensions prevents client stretch. - metadata: frame.metadata, - image: frame.image - }) - ) - return accepted !== false - } - - const schedulePendingFrameRetry = (): void => { - if (pendingFrameTimer || closed || stopping) { - return - } - pendingFrameTimer = setTimeout(() => { - pendingFrameTimer = null - const latest = pendingFrame - pendingFrame = null - if (closed || stopping || !latest) { - return - } - if (emitFrame(latest)) { - ackScreencastFrame(latest.sessionId) - } else { - pendingFrame = latest - schedulePendingFrameRetry() - } - }, BACKPRESSURE_RETRY_MS) - } - - const queueFrame = (frame: PendingScreencastFrame): void => { - if (closed || stopping) { - return - } - const now = Date.now() - const elapsed = now - lastFrameSentAt - if ( - options.minFrameIntervalMs <= 0 || - lastFrameSentAt === 0 || - elapsed >= options.minFrameIntervalMs - ) { - clearPendingFrameTimer(true) - if (emitFrame(frame)) { - ackScreencastFrame(frame.sessionId) - } else { - pendingFrame = frame - schedulePendingFrameRetry() - } - return - } - - // Why: static UI changes can be the last frame Chromium emits. Keep the - // newest throttled frame and flush it after the interval instead of - // dropping it forever. - if (pendingFrame?.sessionId !== frame.sessionId) { - ackScreencastFrame(pendingFrame?.sessionId) - } - pendingFrame = frame - if (pendingFrameTimer) { - return - } - pendingFrameTimer = setTimeout( - () => { - pendingFrameTimer = null - const latest = pendingFrame - pendingFrame = null - if (closed || stopping || !latest) { - return - } - if (emitFrame(latest)) { - ackScreencastFrame(latest.sessionId) - } else { - pendingFrame = latest - schedulePendingFrameRetry() - } - }, - Math.max(0, options.minFrameIntervalMs - elapsed) - ) - } - - const clearDeviceMetricsOverride = async (): Promise => { - if (webContents.isDestroyed() || !dbg.isAttached()) { - deviceMetricsOverridden = false - return - } - await sendDebuggerCommand(dbg, 'Emulation.clearDeviceMetricsOverride') - deviceMetricsOverridden = false - } - - const applyDeviceMetricsOverride = async (): Promise => { - const viewportWidth = positiveInteger(options.viewportWidth) - const viewportHeight = positiveInteger(options.viewportHeight) - if (!viewportWidth || !viewportHeight) { - return - } - const deviceScaleFactor = positiveNumber(options.deviceScaleFactor) ?? 1 - // Why: Back/Forward and cross-process navigations can drop emulation while - // the screencast remains attached. Reapply before fallback captures so the - // page lays out at the client pane size, not the host BrowserView size. - await sendDebuggerCommand(dbg, 'Emulation.setDeviceMetricsOverride', { - width: viewportWidth, - height: viewportHeight, - deviceScaleFactor, - mobile: options.mobile === true - }) - await sendDebuggerCommand(dbg, 'Emulation.setVisibleSize', { - width: viewportWidth, - height: viewportHeight - }).catch(() => {}) - deviceMetricsOverridden = true - } + const deviceMetrics = createBrowserScreencastDeviceMetrics(webContents, dbg, options) + const framePacer = createBrowserScreencastFramePacer({ dbg, options, isClosed, isStopping }) + const snapshotCapture = createBrowserScreencastSnapshotCapture({ + webContents, + dbg, + options, + isClosed, + isStopping, + getSeq: framePacer.getSeq, + queueFrame: framePacer.queueFrame, + applyDeviceMetricsOverride: deviceMetrics.apply + }) + const handleMessage = createBrowserScreencastMessageHandler({ + dbg, + options, + isClosed, + isStopping, + queueFrame: framePacer.queueFrame, + ackScreencastFrame: framePacer.ackFrame, + scheduleNavigationFrameCapture: snapshotCapture.scheduleNavigationFrameCapture, + clearNavigationCaptureTimer: snapshotCapture.clearNavigationCaptureTimer, + bumpSnapshotGeneration: snapshotCapture.bumpGeneration + }) const finish = (): void => { if (closed) { return } closed = true - clearNavigationCaptureTimer() - clearPendingFrameTimer() + snapshotCapture.clearNavigationCaptureTimer() + framePacer.clearPending() dbg.removeListener('message', handleMessage as never) dbg.removeListener('detach', handleDetach as never) debuggerLease?.release() @@ -417,194 +82,12 @@ export async function startBrowserScreencast( finish() } - const handleMessage = (_event: unknown, method: string, params: unknown): void => { - if (closed) { - return - } - if (stopping && method !== 'Page.screencastFrame') { - return - } - if (method === 'Page.javascriptDialogOpening') { - const payload = - params && typeof params === 'object' ? (params as Record) : {} - options.onEvent?.({ - type: 'dialog', - dialogType: typeof payload.type === 'string' ? payload.type : 'alert', - message: typeof payload.message === 'string' ? payload.message : 'Browser dialog' - }) - return - } - if (method === 'Page.javascriptDialogClosed') { - options.onEvent?.({ type: 'dialogClosed' }) - return - } - if (method === 'Page.frameNavigated') { - const payload = - params && typeof params === 'object' ? (params as Record) : {} - const frame = payload.frame && typeof payload.frame === 'object' ? payload.frame : null - if (!frame || !('parentId' in frame)) { - scheduleNavigationFrameCapture() - } - return - } - if (method === 'Page.loadEventFired') { - scheduleNavigationFrameCapture() - return - } - if (method !== 'Page.screencastFrame') { - return - } - const payload = params && typeof params === 'object' ? (params as Record) : {} - const data = typeof payload.data === 'string' ? payload.data : null - const sessionId = typeof payload.sessionId === 'number' ? payload.sessionId : null - if (!data || sessionId === null) { - return - } - if (stopping) { - void sendDebuggerCommand(dbg, 'Page.screencastFrameAck', { sessionId }).catch(() => {}) - return - } - - try { - const image = new Uint8Array(Buffer.from(data, 'base64')) - // Why: image dimension parsing happens for every live frame; share the - // result between stale-frame rejection and metadata enrichment. - const imageSize = readBrowserScreencastImageSize(image, options.format) - if (!isLiveFrameCompatibleWithViewport(imageSize, options)) { - // Why: after tab switches/navigation Chromium can briefly stream the - // host surface instead of the requested client viewport. Dropping that - // frame keeps the client from rendering server-sized blank gutters. - ackScreencastFrame(sessionId) - scheduleNavigationFrameCapture() - return - } - snapshotGeneration += 1 - clearNavigationCaptureTimer() - queueFrame({ - metadata: enrichFrameMetadata(readFrameMetadata(payload.metadata), imageSize, options), - image, - sessionId - }) - } catch { - ackScreencastFrame(sessionId) - } - } - - const scheduleNavigationFrameCapture = (): void => { - if (closed || stopping) { - return - } - clearNavigationCaptureTimer() - const generation = ++snapshotGeneration - // Why: static pages can finish navigation without producing a live - // screencast frame, leaving mobile on the previous page image. - navigationCaptureTimer = setTimeout(() => { - navigationCaptureTimer = null - void emitSnapshotFrame(false, generation) - }, 250) - } - - const isSnapshotStale = (initialOnly: boolean, generation?: number): boolean => - closed || - stopping || - (initialOnly && seq > 0) || - (generation !== undefined && generation !== snapshotGeneration) - - const emitSnapshotFrame = async (initialOnly: boolean, generation?: number): Promise => { - if (isSnapshotStale(initialOnly, generation)) { - return - } - try { - const viewportWidth = positiveInteger(options.viewportWidth) - const viewportHeight = positiveInteger(options.viewportHeight) - let image: Uint8Array | null = null - await applyDeviceMetricsOverride() - if (isSnapshotStale(initialOnly, generation)) { - return - } - if (viewportWidth && viewportHeight && typeof webContents.capturePage === 'function') { - try { - // Why: CDP captureScreenshot can tile BrowserView surfaces under - // mobile emulation; Electron captures the actual visible viewport. - const nativeImage = await webContents.capturePage({ - x: 0, - y: 0, - width: viewportWidth, - height: viewportHeight - }) - const capture = scaleSnapshotToFit(nativeImage, options) - const buffer = - options.format === 'png' ? capture.toPNG() : capture.toJPEG(options.quality) - if (buffer.byteLength > 0) { - image = new Uint8Array(buffer) - } - } catch { - image = null - } - } - // Why: Page.startScreencast may not produce a frame for an already-painted - // blank/static page, which leaves remote browser clients showing only the shell. - if (!image) { - const result = await sendDebuggerCommand(dbg, 'Page.captureScreenshot', { - format: options.format, - ...(options.format === 'jpeg' ? { quality: options.quality } : {}), - ...(viewportWidth && viewportHeight - ? { - // Why: mobile emulation + DPR can make Chromium capture a larger - // surface than the visual viewport. Clipping keeps fallback frames - // in the same coordinate space as live screencast frames. - clip: { - x: 0, - y: 0, - width: viewportWidth, - height: viewportHeight, - scale: 1 - } - } - : {}), - captureBeyondViewport: false - }) - if (isSnapshotStale(initialOnly, generation)) { - return - } - const payload = - result && typeof result === 'object' ? (result as Record) : {} - const data = typeof payload.data === 'string' ? payload.data : null - if (!data) { - return - } - image = new Uint8Array(Buffer.from(data, 'base64')) - } - if (isSnapshotStale(initialOnly, generation)) { - return - } - const imageSize = readBrowserScreencastImageSize(image, options.format) - const baseMetadata = - viewportWidth && viewportHeight - ? { deviceWidth: viewportWidth, deviceHeight: viewportHeight } - : imageSize - ? { deviceWidth: imageSize.width, deviceHeight: imageSize.height } - : {} - queueFrame({ - // Why: static pages may only produce this fallback capture. Without - // dimensions, mobile clients stretch it to the phone aspect ratio. - metadata: { - ...baseMetadata, - ...(imageSize ? { imageWidth: imageSize.width, imageHeight: imageSize.height } : {}) - }, - image - }) - } catch { - // Best effort only: live Page.screencastFrame events still drive the stream. - } - } - dbg.on('message', handleMessage as never) dbg.on('detach', handleDetach as never) try { await sendDebuggerCommand(dbg, 'Page.enable') - await applyDeviceMetricsOverride() + await deviceMetrics.apply() await sendDebuggerCommand(dbg, 'Page.startScreencast', { format: options.format, quality: options.quality, @@ -612,10 +95,10 @@ export async function startBrowserScreencast( maxHeight: options.maxHeight, everyNthFrame: options.everyNthFrame }) - void emitSnapshotFrame(true) + void snapshotCapture.emitSnapshotFrame(true) } catch (error) { - if (deviceMetricsOverridden) { - await clearDeviceMetricsOverride().catch(() => {}) + if (deviceMetrics.isOverridden()) { + await deviceMetrics.clear().catch(() => {}) } finish() throw new BrowserError( @@ -630,14 +113,14 @@ export async function startBrowserScreencast( return } stopping = true - snapshotGeneration += 1 - clearNavigationCaptureTimer() - clearPendingFrameTimer(true) + snapshotCapture.bumpGeneration() + snapshotCapture.clearNavigationCaptureTimer() + framePacer.clearPending(true) try { void (async () => { await sendDebuggerCommand(dbg, 'Page.stopScreencast').catch(() => {}) - if (deviceMetricsOverridden) { - await clearDeviceMetricsOverride().catch(() => {}) + if (deviceMetrics.isOverridden()) { + await deviceMetrics.clear().catch(() => {}) } })().finally(finish) } catch { diff --git a/src/main/browser/browser-screencast-viewport-fit.ts b/src/main/browser/browser-screencast-viewport-fit.ts new file mode 100644 index 00000000000..1faa2f718de --- /dev/null +++ b/src/main/browser/browser-screencast-viewport-fit.ts @@ -0,0 +1,91 @@ +import type { NativeImage } from 'electron' +import type { + BrowserScreencastOptions, + ScreencastImageSize +} from './browser-screencast-stream-types' + +function isNear(value: number, expected: number): boolean { + return Math.abs(value - expected) <= Math.max(2, expected * 0.02) +} + +function scaleToFit( + width: number, + height: number, + maxWidth: number, + maxHeight: number +): { + width: number + height: number +} { + const scale = Math.min(1, maxWidth / width, maxHeight / height) + return { + width: Math.round(width * scale), + height: Math.round(height * scale) + } +} + +// Why: capturePage returns device pixels, so a hi-DPI viewport yields a bitmap several times +// larger than the live path is allowed to send. Apply the caller's cap here too. +export function scaleSnapshotToFit( + image: NativeImage, + options: BrowserScreencastOptions +): NativeImage { + const size = image.getSize() + if (!size.width || !size.height) { + return image + } + const fitted = scaleToFit(size.width, size.height, options.maxWidth, options.maxHeight) + if (fitted.width === size.width && fitted.height === size.height) { + return image + } + return image.resize(fitted) +} + +function isNearSize( + actual: { width: number; height: number }, + expected: { width: number; height: number } +): boolean { + return isNear(actual.width, expected.width) && isNear(actual.height, expected.height) +} + +export function isLiveFrameCompatibleWithViewport( + imageSize: ScreencastImageSize | null, + options: BrowserScreencastOptions +): boolean { + const viewportWidth = positiveInteger(options.viewportWidth) + const viewportHeight = positiveInteger(options.viewportHeight) + if (!viewportWidth || !viewportHeight) { + return true + } + if (!imageSize) { + return true + } + const deviceScaleFactor = positiveNumber(options.deviceScaleFactor) ?? 1 + const cssViewport = { width: viewportWidth, height: viewportHeight } + const deviceViewport = { + width: Math.round(viewportWidth * deviceScaleFactor), + height: Math.round(viewportHeight * deviceScaleFactor) + } + const scaledDeviceViewport = scaleToFit( + deviceViewport.width, + deviceViewport.height, + options.maxWidth, + options.maxHeight + ) + // Why: Chromium can stream CSS-sized, DPR-sized, or maxWidth/maxHeight-scaled + // bitmaps for the same emulated viewport. All are client-authoritative; stale + // host BrowserView frames are the incompatible ones we need to drop. + return ( + isNearSize(imageSize, cssViewport) || + isNearSize(imageSize, deviceViewport) || + isNearSize(imageSize, scaledDeviceViewport) + ) +} + +export function positiveInteger(value: number | undefined): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : null +} + +export function positiveNumber(value: number | undefined): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null +} diff --git a/src/main/browser/browser-session-cookie-staging.ts b/src/main/browser/browser-session-cookie-staging.ts new file mode 100644 index 00000000000..70ba92f8ec4 --- /dev/null +++ b/src/main/browser/browser-session-cookie-staging.ts @@ -0,0 +1,137 @@ +import { app } from 'electron' +import { copyFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' +import { loadBrowserSessionMeta, persistBrowserSessionMeta } from './browser-session-meta-store' +import { isValidPersistedBrowserSessionProfile } from './browser-session-persisted-profile-validation' +import { resolveChromiumCookiesPath } from './chromium-cookie-path' + +type PendingCookieImportTarget = { + // Why: lazy so a pre-ready app.getPath('userData') throw is swallowed where it always was. + resolveMetadataPath: () => string + defaultPartition: string +} + +function partitionCookiesPath(partition: string): string { + const partitionName = partition.replace('persist:', '') + const partitionDir = join(app.getPath('userData'), 'Partitions', partitionName) + // Why: replay must overwrite the same (modern or legacy) DB the importing partition already uses. + return resolveChromiumCookiesPath(partitionDir) ?? join(partitionDir, 'Cookies') +} + +// Why: must run before any session.fromPartition() so CookieMonster reads the staged cookies instead of overwriting them from its in-memory DB. +export function applyPendingBrowserCookieImports({ + resolveMetadataPath, + defaultPartition, + activeOrcaProfileId +}: PendingCookieImportTarget & { activeOrcaProfileId: string }): void { + try { + const meta = loadBrowserSessionMeta(resolveMetadataPath, defaultPartition) + const pendingEntries = Object.entries(meta.pendingCookieImports) + if (pendingEntries.length === 0) { + return + } + // Why: replay writes to partition-derived paths, so corrupted metadata must pass the same validation as the webview allowlist. + const knownPartitions = new Set([defaultPartition]) + for (const profile of meta.profiles) { + if (isValidPersistedBrowserSessionProfile(profile, activeOrcaProfileId)) { + knownPartitions.add(profile.partition) + } + } + const remainingEntries = { ...meta.pendingCookieImports } + + for (const [partition, stagedPath] of pendingEntries) { + if (!knownPartitions.has(partition)) { + delete remainingEntries[partition] + continue + } + if (!existsSync(stagedPath)) { + delete remainingEntries[partition] + continue + } + + const liveCookiesPath = partitionCookiesPath(partition) + try { + mkdirSync(join(liveCookiesPath, '..'), { recursive: true }) + copyFileSync(stagedPath, liveCookiesPath) + // Why: stale WAL/SHM sidecars would corrupt CookieMonster's read of the freshly swapped DB. + let sidecarCopyFailed = false + for (const suffix of ['-wal', '-shm']) { + try { + unlinkSync(liveCookiesPath + suffix) + } catch { + /* may not exist */ + } + const stagingSidecar = stagedPath + suffix + if (!existsSync(stagingSidecar)) { + continue + } + try { + copyFileSync(stagingSidecar, liveCookiesPath + suffix) + } catch { + sidecarCopyFailed = true + } + } + if (sidecarCopyFailed) { + // Why: sidecar copy failed → inconsistent replay; keep this entry for retry. + continue + } + for (const ext of ['', '-wal', '-shm']) { + try { + unlinkSync(`${stagedPath}${ext}`) + } catch { + /* best-effort */ + } + } + delete remainingEntries[partition] + } catch { + // Why: keep this entry for retry — one partition's failed replay shouldn't drop unrelated entries. + } + } + persistBrowserSessionMeta(resolveMetadataPath, defaultPartition, { + pendingCookieImports: remainingEntries, + pendingCookieDbPath: remainingEntries[defaultPartition] ?? null + }) + } catch { + // best-effort — if this fails, CookieMonster loads the old DB + } +} + +export function setPendingBrowserCookieImport({ + resolveMetadataPath, + defaultPartition, + partition, + stagingDbPath +}: PendingCookieImportTarget & { partition: string; stagingDbPath: string }): void { + const meta = loadBrowserSessionMeta(resolveMetadataPath, defaultPartition) + const pendingCookieImports = { ...meta.pendingCookieImports, [partition]: stagingDbPath } + persistBrowserSessionMeta(resolveMetadataPath, defaultPartition, { + pendingCookieImports, + pendingCookieDbPath: pendingCookieImports[defaultPartition] ?? null + }) +} + +// Why: a degraded import still rewrites the live session, so an older staged DB must stop replaying over it. +export function clearPendingBrowserCookieImport({ + resolveMetadataPath, + defaultPartition, + partition +}: PendingCookieImportTarget & { partition: string }): void { + const meta = loadBrowserSessionMeta(resolveMetadataPath, defaultPartition) + if (!(partition in meta.pendingCookieImports)) { + return + } + const pendingCookieImports = { ...meta.pendingCookieImports } + const stagedPath = pendingCookieImports[partition] + delete pendingCookieImports[partition] + persistBrowserSessionMeta(resolveMetadataPath, defaultPartition, { + pendingCookieImports, + pendingCookieDbPath: pendingCookieImports[defaultPartition] ?? null + }) + for (const suffix of ['', '-wal', '-shm']) { + try { + unlinkSync(stagedPath + suffix) + } catch { + /* best-effort */ + } + } +} diff --git a/src/main/browser/browser-session-meta-store.ts b/src/main/browser/browser-session-meta-store.ts new file mode 100644 index 00000000000..6fb8eeb3a15 --- /dev/null +++ b/src/main/browser/browser-session-meta-store.ts @@ -0,0 +1,66 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import type { BrowserSessionProfile } from '../../shared/browser-workspace-types' + +// Why: no userAgent fields — the session UA is always derived from the running +// engine at startup (clean or native), never persisted. Imports before Aug 2026 +// stored a synthesized source-browser UA here; persistMeta drops those legacy +// keys on the next write because this loader no longer carries them. +export type BrowserSessionMeta = { + defaultSource: BrowserSessionProfile['source'] + pendingCookieDbPath: string | null + pendingCookieImports: Record + profiles: BrowserSessionProfile[] +} + +export const BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' + +// Why: the path is resolved lazily (app.getPath('userData') throws pre-ready) so the failure lands inside the existing swallow. +export function loadBrowserSessionMeta( + resolveMetadataPath: () => string, + defaultPartition: string +): BrowserSessionMeta { + try { + const raw = readFileSync(resolveMetadataPath(), 'utf-8') + const data = JSON.parse(raw) + const legacyPendingCookieDbPath = + typeof data?.pendingCookieDbPath === 'string' ? data.pendingCookieDbPath : null + const pendingCookieImports: Record = + data && typeof data.pendingCookieImports === 'object' && data.pendingCookieImports + ? { ...data.pendingCookieImports } + : {} + if (legacyPendingCookieDbPath && !pendingCookieImports[defaultPartition]) { + pendingCookieImports[defaultPartition] = legacyPendingCookieDbPath + } + return { + defaultSource: data?.defaultSource ?? null, + pendingCookieDbPath: legacyPendingCookieDbPath, + pendingCookieImports, + profiles: Array.isArray(data?.profiles) ? data.profiles : [] + } + } catch { + return { + defaultSource: null, + pendingCookieDbPath: null, + pendingCookieImports: {}, + profiles: [] + } + } +} + +// Why: write-temp-then-rename is atomic, so a crash mid-write can't corrupt the live file. +export function persistBrowserSessionMeta( + resolveMetadataPath: () => string, + defaultPartition: string, + updates: Partial +): void { + try { + const existing = loadBrowserSessionMeta(resolveMetadataPath, defaultPartition) + const tmpPath = `${resolveMetadataPath()}.tmp` + mkdirSync(dirname(resolveMetadataPath()), { recursive: true }) + writeFileSync(tmpPath, JSON.stringify({ ...existing, ...updates })) + renameSync(tmpPath, resolveMetadataPath()) + } catch { + // best-effort + } +} diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts new file mode 100644 index 00000000000..e31691b9646 --- /dev/null +++ b/src/main/browser/browser-session-partition-policies.ts @@ -0,0 +1,126 @@ +import { session } from 'electron' +import type { Session } from 'electron' +import type { BrowserSessionProfile } from '../../shared/browser-workspace-types' +import { browserManager } from './browser-manager' +import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' +import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy' +import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua' +import { setBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' +import { + allowsBrowserWebAuthnPermission, + clearBrowserWebAuthnAccessHandlers, + installBrowserWebAuthnAccessHandlers +} from './browser-webauthn-access' + +// Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart. +const configuredPartitions = new Set() +const handleWillDownload = ( + _event: Electron.Event, + item: Electron.DownloadItem, + webContents: Electron.WebContents +): void => { + browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) +} + +export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void { + const { partition } = profile + const sess = session.fromPartition(partition) + setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean') + if (configuredPartitions.has(partition)) { + return + } + + browserManager.installCertificateRequestGuard(sess) + if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') { + const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) + sess.setUserAgent(cleanUA) + setupClientHintsOverride(sess, cleanUA) + } + sess.setPermissionRequestHandler((webContents, permission, callback, details) => { + // Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS. + if (permission === 'media') { + void requestSystemMediaAccess( + details as Electron.MediaAccessPermissionRequest | undefined + ).then( + (granted) => { + if (!granted) { + browserManager.notifyPermissionDenied({ + guestWebContentsId: webContents.id, + permission, + rawUrl: webContents.getURL() + }) + } + callback(granted) + }, + (error: unknown) => { + console.error('[permissions] Browser media access failed:', error) + browserManager.notifyPermissionDenied({ + guestWebContentsId: webContents.id, + permission, + rawUrl: webContents.getURL() + }) + callback(false) + } + ) + return + } + const allowed = isAutoGrantedBrowserSessionPermission(permission) + if (!allowed) { + browserManager.notifyPermissionDenied({ + guestWebContentsId: webContents.id, + permission, + rawUrl: webContents.getURL() + }) + } + callback(allowed) + }) + sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => { + if (permission === 'media') { + return hasSystemMediaAccess(details?.mediaType) + } + if (allowsBrowserWebAuthnPermission(permission, details)) { + return true + } + return isAutoGrantedBrowserSessionPermission(permission) + }) + installBrowserWebAuthnAccessHandlers(sess) + sess.setDisplayMediaRequestHandler((_request, callback) => { + callback({ video: undefined, audio: undefined }) + }) + sess.removeListener('will-download', handleWillDownload) + sess.on('will-download', handleWillDownload) + configuredPartitions.add(partition) +} + +export function clearBrowserSessionPartitionPolicies(partition: string, sess: Session): void { + // Why: the Electron Session survives partition deletion; clear callbacks/listeners so removed profiles don't retain closures. + configuredPartitions.delete(partition) + browserManager.removeCertificateRequestGuard(sess) + sess.removeListener('will-download', handleWillDownload) + clearBrowserWebAuthnAccessHandlers(sess) + sess.setPermissionRequestHandler(null) + sess.setPermissionCheckHandler(null) + sess.setDisplayMediaRequestHandler(null) +} + +export function applyBrowserSessionUserAgentModes(profiles: BrowserSessionProfile[]): void { + for (const profile of profiles) { + const partition = profile.partition + try { + const sess = session.fromPartition(partition) + const userAgentMode = profile.userAgentMode ?? 'clean' + setBrowserSessionUserAgentMode(sess, userAgentMode) + + if (profile.userAgentMode === 'native') { + continue + } + + // Why: the default Electron UA leaks "Electron/X.X.X" + app name, which trips Cloudflare Turnstile. + const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) + sess.setUserAgent(cleanUA) + setupClientHintsOverride(sess, cleanUA) + } catch { + /* session not available yet (e.g. unit tests or pre-ready) */ + } + } +} diff --git a/src/main/browser/browser-session-persisted-profile-validation.ts b/src/main/browser/browser-session-persisted-profile-validation.ts new file mode 100644 index 00000000000..31e4f69f9fb --- /dev/null +++ b/src/main/browser/browser-session-persisted-profile-validation.ts @@ -0,0 +1,38 @@ +import { getOrcaProfileBrowserSessionPartition } from '../../shared/orca-profiles' +import type { BrowserSessionProfile } from '../../shared/browser-workspace-types' + +const BROWSER_SESSION_PROFILE_ID_RE = + /^[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/ + +// Why: validate on-disk profile shape so a tampered JSON file can't inject an arbitrary partition into the will-attach-webview allowlist. +export function isValidPersistedBrowserSessionProfile( + profile: unknown, + activeOrcaProfileId: string +): profile is BrowserSessionProfile { + if (!profile || typeof profile !== 'object') { + return false + } + const candidate = profile as Partial + return ( + candidate.id !== 'default' && + candidate.scope !== 'default' && + typeof candidate.id === 'string' && + typeof candidate.partition === 'string' && + typeof candidate.label === 'string' && + (candidate.userAgentMode === undefined || + candidate.userAgentMode === 'clean' || + candidate.userAgentMode === 'native') && + isProfileOwnedSessionPartition(candidate.id, candidate.partition, activeOrcaProfileId) + ) +} + +function isProfileOwnedSessionPartition( + profileId: string, + partition: string, + activeOrcaProfileId: string +): boolean { + return ( + BROWSER_SESSION_PROFILE_ID_RE.test(profileId) && + partition === getOrcaProfileBrowserSessionPartition(activeOrcaProfileId, profileId) + ) +} diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 79296a0cdbf..f8db8ab54a0 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -1,17 +1,6 @@ -/* eslint-disable max-lines -- Why: single source of truth for browser session profiles, partition allowlisting, cookie staging, and per-partition policies; splitting scatters the security boundary. */ import { app, session } from 'electron' -import type { Session } from 'electron' import { randomUUID } from 'node:crypto' -import { - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - renameSync, - unlinkSync, - writeFileSync -} from 'node:fs' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' import { DEFAULT_LOCAL_ORCA_PROFILE_ID, @@ -23,41 +12,30 @@ import type { BrowserSessionProfileCreateOptions, BrowserSessionProfileScope } from '../../shared/browser-workspace-types' -import { browserManager } from './browser-manager' -import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' -import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua' import { - clearBrowserSessionUserAgentMode, - setBrowserSessionUserAgentMode -} from './browser-session-user-agent-mode' -import { resolveChromiumCookiesPath } from './chromium-cookie-path' -import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy' + applyPendingBrowserCookieImports, + clearPendingBrowserCookieImport, + setPendingBrowserCookieImport +} from './browser-session-cookie-staging' import { - allowsBrowserWebAuthnPermission, - clearBrowserWebAuthnAccessHandlers, - installBrowserWebAuthnAccessHandlers -} from './browser-webauthn-access' - -// Why: no userAgent fields — the session UA is always derived from the running -// engine at startup (clean or native), never persisted. Imports before Aug 2026 -// stored a synthesized source-browser UA here; persistMeta drops those legacy -// keys on the next write because this loader no longer carries them. -type BrowserSessionMeta = { - defaultSource: BrowserSessionProfile['source'] - pendingCookieDbPath: string | null - pendingCookieImports: Record - profiles: BrowserSessionProfile[] -} + BROWSER_SESSION_META_FILE_NAME, + loadBrowserSessionMeta, + persistBrowserSessionMeta +} from './browser-session-meta-store' +import type { BrowserSessionMeta } from './browser-session-meta-store' +import { + applyBrowserSessionUserAgentModes, + clearBrowserSessionPartitionPolicies, + installBrowserSessionPartitionPolicies +} from './browser-session-partition-policies' +import { isValidPersistedBrowserSessionProfile } from './browser-session-persisted-profile-validation' +import { clearBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' export type BrowserSessionRegistryProfileOptions = { orcaProfileId: string profileDirectory: string } -const BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' -const BROWSER_SESSION_PROFILE_ID_RE = - /^[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/ - // Why: source of truth for valid partitions; will-attach-webview consults it so a compromised renderer can't smuggle in an arbitrary partition. class BrowserSessionRegistry { @@ -100,24 +78,8 @@ class BrowserSessionRegistry { return this.loadPersistedMeta().defaultSource } - private static partitionCookiesPath(partition: string): string { - const partitionName = partition.replace('persist:', '') - const partitionDir = join(app.getPath('userData'), 'Partitions', partitionName) - // Why: replay must overwrite the same (modern or legacy) DB the importing partition already uses. - return resolveChromiumCookiesPath(partitionDir) ?? join(partitionDir, 'Cookies') - } - - // Why: write-temp-then-rename is atomic, so a crash mid-write can't corrupt the live file. private persistMeta(updates: Partial): void { - try { - const existing = this.loadPersistedMeta() - const tmpPath = `${this.metadataPath}.tmp` - mkdirSync(dirname(this.metadataPath), { recursive: true }) - writeFileSync(tmpPath, JSON.stringify({ ...existing, ...updates })) - renameSync(tmpPath, this.metadataPath) - } catch { - // best-effort - } + persistBrowserSessionMeta(() => this.metadataPath, this.defaultPartition, updates) } private persistSource(source: BrowserSessionProfile['source']): void { @@ -131,32 +93,7 @@ class BrowserSessionRegistry { } private loadPersistedMeta(): BrowserSessionMeta { - try { - const raw = readFileSync(this.metadataPath, 'utf-8') - const data = JSON.parse(raw) - const legacyPendingCookieDbPath = - typeof data?.pendingCookieDbPath === 'string' ? data.pendingCookieDbPath : null - const pendingCookieImports: Record = - data && typeof data.pendingCookieImports === 'object' && data.pendingCookieImports - ? { ...data.pendingCookieImports } - : {} - if (legacyPendingCookieDbPath && !pendingCookieImports[this.defaultPartition]) { - pendingCookieImports[this.defaultPartition] = legacyPendingCookieDbPath - } - return { - defaultSource: data?.defaultSource ?? null, - pendingCookieDbPath: legacyPendingCookieDbPath, - pendingCookieImports, - profiles: Array.isArray(data?.profiles) ? data.profiles : [] - } - } catch { - return { - defaultSource: null, - pendingCookieDbPath: null, - pendingCookieImports: {}, - profiles: [] - } - } + return loadBrowserSessionMeta(() => this.metadataPath, this.defaultPartition) } // Why: run before any webview loads, and set the UA before the first request or Electron's default UA invalidates imported cookies. @@ -174,132 +111,36 @@ class BrowserSessionRegistry { } // Why: nothing else installs policies on the default partition (hydrate skips it), so without this its guest permissions would be denied. - this.setupSessionPolicies(this.getDefaultProfile()) + installBrowserSessionPartitionPolicies(this.getDefaultProfile()) - for (const profile of this.listProfiles()) { - const partition = profile.partition - try { - const sess = session.fromPartition(partition) - const userAgentMode = profile.userAgentMode ?? 'clean' - setBrowserSessionUserAgentMode(sess, userAgentMode) - - if (profile.userAgentMode === 'native') { - continue - } - - // Why: the default Electron UA leaks "Electron/X.X.X" + app name, which trips Cloudflare Turnstile. - const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) - sess.setUserAgent(cleanUA) - setupClientHintsOverride(sess, cleanUA) - } catch { - /* session not available yet (e.g. unit tests or pre-ready) */ - } - } + applyBrowserSessionUserAgentModes(this.listProfiles()) } // Why: must run before any session.fromPartition() so CookieMonster reads the staged cookies instead of overwriting them from its in-memory DB. applyPendingCookieImport(): void { - try { - const meta = this.loadPersistedMeta() - const pendingEntries = Object.entries(meta.pendingCookieImports) - if (pendingEntries.length === 0) { - return - } - // Why: replay writes to partition-derived paths, so corrupted metadata must pass the same validation as the webview allowlist. - const knownPartitions = new Set([this.defaultPartition]) - for (const profile of meta.profiles) { - if (this.isValidPersistedProfile(profile)) { - knownPartitions.add(profile.partition) - } - } - const remainingEntries = { ...meta.pendingCookieImports } - - for (const [partition, stagedPath] of pendingEntries) { - if (!knownPartitions.has(partition)) { - delete remainingEntries[partition] - continue - } - if (!existsSync(stagedPath)) { - delete remainingEntries[partition] - continue - } - - const liveCookiesPath = BrowserSessionRegistry.partitionCookiesPath(partition) - try { - mkdirSync(join(liveCookiesPath, '..'), { recursive: true }) - copyFileSync(stagedPath, liveCookiesPath) - // Why: stale WAL/SHM sidecars would corrupt CookieMonster's read of the freshly swapped DB. - let sidecarCopyFailed = false - for (const suffix of ['-wal', '-shm']) { - try { - unlinkSync(liveCookiesPath + suffix) - } catch { - /* may not exist */ - } - const stagingSidecar = stagedPath + suffix - if (!existsSync(stagingSidecar)) { - continue - } - try { - copyFileSync(stagingSidecar, liveCookiesPath + suffix) - } catch { - sidecarCopyFailed = true - } - } - if (sidecarCopyFailed) { - // Why: sidecar copy failed → inconsistent replay; keep this entry for retry. - continue - } - for (const ext of ['', '-wal', '-shm']) { - try { - unlinkSync(`${stagedPath}${ext}`) - } catch { - /* best-effort */ - } - } - delete remainingEntries[partition] - } catch { - // Why: keep this entry for retry — one partition's failed replay shouldn't drop unrelated entries. - } - } - this.persistMeta({ - pendingCookieImports: remainingEntries, - pendingCookieDbPath: remainingEntries[this.defaultPartition] ?? null - }) - } catch { - // best-effort — if this fails, CookieMonster loads the old DB - } + applyPendingBrowserCookieImports({ + resolveMetadataPath: () => this.metadataPath, + defaultPartition: this.defaultPartition, + activeOrcaProfileId: this.activeOrcaProfileId + }) } setPendingCookieImport(partition: string, stagingDbPath: string): void { - const meta = this.loadPersistedMeta() - const pendingCookieImports = { ...meta.pendingCookieImports, [partition]: stagingDbPath } - this.persistMeta({ - pendingCookieImports, - pendingCookieDbPath: pendingCookieImports[this.defaultPartition] ?? null + setPendingBrowserCookieImport({ + resolveMetadataPath: () => this.metadataPath, + defaultPartition: this.defaultPartition, + partition, + stagingDbPath }) } // Why: a degraded import still rewrites the live session, so an older staged DB must stop replaying over it. clearPendingCookieImport(partition: string): void { - const meta = this.loadPersistedMeta() - if (!(partition in meta.pendingCookieImports)) { - return - } - const pendingCookieImports = { ...meta.pendingCookieImports } - const stagedPath = pendingCookieImports[partition] - delete pendingCookieImports[partition] - this.persistMeta({ - pendingCookieImports, - pendingCookieDbPath: pendingCookieImports[this.defaultPartition] ?? null + clearPendingBrowserCookieImport({ + resolveMetadataPath: () => this.metadataPath, + defaultPartition: this.defaultPartition, + partition }) - for (const suffix of ['', '-wal', '-shm']) { - try { - unlinkSync(stagedPath + suffix) - } catch { - /* best-effort */ - } - } } getDefaultProfile(): BrowserSessionProfile { @@ -362,7 +203,7 @@ class BrowserSessionRegistry { ...(options.userAgentMode ? { userAgentMode: options.userAgentMode } : {}) } this.profiles.set(id, profile) - this.setupSessionPolicies(profile) + installBrowserSessionPartitionPolicies(profile) this.persistProfiles() return profile } @@ -404,7 +245,7 @@ class BrowserSessionRegistry { try { const sess = session.fromPartition(profile.partition) clearBrowserSessionUserAgentMode(sess) - this.clearSessionPolicies(profile.partition, sess) + clearBrowserSessionPartitionPolicies(profile.partition, sess) await sess.clearStorageData() await sess.clearCache() } catch { @@ -438,134 +279,17 @@ class BrowserSessionRegistry { } } - // Why: validate on-disk profile shape so a tampered JSON file can't inject an arbitrary partition into the will-attach-webview allowlist. - private isValidPersistedProfile(profile: unknown): profile is BrowserSessionProfile { - if (!profile || typeof profile !== 'object') { - return false - } - const candidate = profile as Partial - return ( - candidate.id !== 'default' && - candidate.scope !== 'default' && - typeof candidate.id === 'string' && - typeof candidate.partition === 'string' && - typeof candidate.label === 'string' && - (candidate.userAgentMode === undefined || - candidate.userAgentMode === 'clean' || - candidate.userAgentMode === 'native') && - this.isProfileOwnedSessionPartition(candidate.id, candidate.partition) - ) - } - - private isProfileOwnedSessionPartition(profileId: string, partition: string): boolean { - return ( - BROWSER_SESSION_PROFILE_ID_RE.test(profileId) && - partition === getOrcaProfileBrowserSessionPartition(this.activeOrcaProfileId, profileId) - ) - } - hydrateFromPersisted(profiles: BrowserSessionProfile[]): void { for (const profile of profiles) { - if (!this.isValidPersistedProfile(profile)) { + if (!isValidPersistedBrowserSessionProfile(profile, this.activeOrcaProfileId)) { continue } this.profiles.set(profile.id, profile) if (profile.partition !== this.defaultPartition) { - this.setupSessionPolicies(profile) + installBrowserSessionPartitionPolicies(profile) } } } - - // Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart. - private readonly configuredPartitions = new Set() - private readonly handleWillDownload = ( - _event: Electron.Event, - item: Electron.DownloadItem, - webContents: Electron.WebContents - ): void => { - browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) - } - - private setupSessionPolicies(profile: BrowserSessionProfile): void { - const { partition } = profile - const sess = session.fromPartition(partition) - setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean') - if (this.configuredPartitions.has(partition)) { - return - } - - browserManager.installCertificateRequestGuard(sess) - if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') { - const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) - sess.setUserAgent(cleanUA) - setupClientHintsOverride(sess, cleanUA) - } - sess.setPermissionRequestHandler((webContents, permission, callback, details) => { - // Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS. - if (permission === 'media') { - void requestSystemMediaAccess( - details as Electron.MediaAccessPermissionRequest | undefined - ).then( - (granted) => { - if (!granted) { - browserManager.notifyPermissionDenied({ - guestWebContentsId: webContents.id, - permission, - rawUrl: webContents.getURL() - }) - } - callback(granted) - }, - (error: unknown) => { - console.error('[permissions] Browser media access failed:', error) - browserManager.notifyPermissionDenied({ - guestWebContentsId: webContents.id, - permission, - rawUrl: webContents.getURL() - }) - callback(false) - } - ) - return - } - const allowed = isAutoGrantedBrowserSessionPermission(permission) - if (!allowed) { - browserManager.notifyPermissionDenied({ - guestWebContentsId: webContents.id, - permission, - rawUrl: webContents.getURL() - }) - } - callback(allowed) - }) - sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => { - if (permission === 'media') { - return hasSystemMediaAccess(details?.mediaType) - } - if (allowsBrowserWebAuthnPermission(permission, details)) { - return true - } - return isAutoGrantedBrowserSessionPermission(permission) - }) - installBrowserWebAuthnAccessHandlers(sess) - sess.setDisplayMediaRequestHandler((_request, callback) => { - callback({ video: undefined, audio: undefined }) - }) - sess.removeListener('will-download', this.handleWillDownload) - sess.on('will-download', this.handleWillDownload) - this.configuredPartitions.add(partition) - } - - private clearSessionPolicies(partition: string, sess: Session): void { - // Why: the Electron Session survives partition deletion; clear callbacks/listeners so removed profiles don't retain closures. - this.configuredPartitions.delete(partition) - browserManager.removeCertificateRequestGuard(sess) - sess.removeListener('will-download', this.handleWillDownload) - clearBrowserWebAuthnAccessHandlers(sess) - sess.setPermissionRequestHandler(null) - sess.setPermissionCheckHandler(null) - sess.setDisplayMediaRequestHandler(null) - } } export const browserSessionRegistry = new BrowserSessionRegistry() diff --git a/src/main/browser/cdp-client-response-writer.ts b/src/main/browser/cdp-client-response-writer.ts new file mode 100644 index 00000000000..b3283fa299d --- /dev/null +++ b/src/main/browser/cdp-client-response-writer.ts @@ -0,0 +1,58 @@ +import { WebSocket } from 'ws' + +/** + * Serializes CDP replies to the connected websocket client and echoes the + * request's sessionId back onto its response. + */ +export class CdpClientResponseWriter { + private readonly responseSessionIdsByClient = new WeakMap>() + + constructor(private readonly getClient: () => WebSocket | null) {} + + send(payload: unknown, client = this.getClient()): void { + const responsePayload = client ? this.addResponseSessionId(payload, client) : payload + if (client?.readyState === WebSocket.OPEN) { + client.send(JSON.stringify(responsePayload)) + } + } + + private addResponseSessionId(payload: unknown, client: WebSocket): unknown { + if (typeof payload !== 'object' || payload === null) { + return payload + } + const clientId = (payload as { id?: unknown }).id + if (typeof clientId !== 'number') { + return payload + } + const responseSessionIds = this.responseSessionIdsByClient.get(client) + const sessionId = responseSessionIds?.get(clientId) + responseSessionIds?.delete(clientId) + return sessionId ? { ...payload, sessionId } : payload + } + + sendResult(clientId: number, result: unknown, client = this.getClient()): void { + this.send({ id: clientId, result }, client) + } + + sendError(clientId: number, message: string, client = this.getClient()): void { + this.send({ id: clientId, error: { code: -32000, message } }, client) + } + + isActiveClient(client: WebSocket): boolean { + return this.getClient() === client && client.readyState === WebSocket.OPEN + } + + recordRequestSessionId(client: WebSocket, clientId: number, msg: { sessionId?: string }): void { + const responseSessionIds = this.responseSessionIdsByClient.get(client) ?? new Map() + if (msg.sessionId) { + responseSessionIds.set(clientId, msg.sessionId) + } else { + responseSessionIds.delete(clientId) + } + this.responseSessionIdsByClient.set(client, responseSessionIds) + } + + forgetClient(client: WebSocket): void { + this.responseSessionIdsByClient.delete(client) + } +} diff --git a/src/main/browser/cdp-debugger-channel.ts b/src/main/browser/cdp-debugger-channel.ts new file mode 100644 index 00000000000..352bc741ef0 --- /dev/null +++ b/src/main/browser/cdp-debugger-channel.ts @@ -0,0 +1,126 @@ +import { WebSocket } from 'ws' +import type { WebContents } from 'electron' +import { ANTI_DETECTION_SCRIPT } from './anti-detection' +import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease' +import type { CdpClientResponseWriter } from './cdp-client-response-writer' +import type { CdpSyntheticSessionRegistry } from './cdp-synthetic-session-registry' + +/** + * The IO boundary with webContents.debugger: lease-based attach, event fan-out to + * the websocket client, detach teardown, and generic command forwarding. + */ +export class CdpDebuggerChannel { + private debuggerMessageHandler: ((...args: unknown[]) => void) | null = null + private debuggerDetachHandler: ((...args: unknown[]) => void) | null = null + private debuggerLease: ElectronDebuggerLease | null = null + private attached = false + + constructor( + private readonly webContents: WebContents, + private readonly responder: CdpClientResponseWriter, + private readonly sessions: CdpSyntheticSessionRegistry, + private readonly getClient: () => WebSocket | null, + private readonly onDetached: () => void + ) {} + + async attachDebugger(): Promise { + if (this.attached) { + return + } + try { + this.debuggerLease = acquireElectronDebugger(this.webContents) + } catch { + throw new Error('Could not attach debugger. DevTools may already be open for this tab.') + } + this.attached = true + + // Why: attaching the CDP debugger sets navigator.webdriver = true and + // exposes other automation signals that Cloudflare Turnstile checks. + // Inject before any page loads so challenges succeed. + try { + await this.webContents.debugger.sendCommand('Page.enable', {}) + await this.webContents.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', { + source: ANTI_DETECTION_SCRIPT + }) + } catch { + /* best-effort — page domain may not be ready yet */ + } + + this.debuggerMessageHandler = (_event: unknown, ...rest: unknown[]) => { + const [method, params, sessionId] = rest as [ + string, + Record, + string | undefined + ] + const client = this.getClient() + if (!client || client.readyState !== WebSocket.OPEN) { + return + } + // Why: Electron passes empty string (not undefined) for root-session events, but + // agent-browser filters events by the sessionId from Target.attachToTarget. + const msg: Record = { method, params } + msg.sessionId = sessionId || this.sessions.primarySessionId + client.send(JSON.stringify(msg)) + } + this.debuggerDetachHandler = () => { + this.attached = false + const lease = this.debuggerLease + this.debuggerLease = null + lease?.release() + this.onDetached() + } + this.webContents.debugger.on('message', this.debuggerMessageHandler as never) + this.webContents.debugger.on('detach', this.debuggerDetachHandler as never) + } + + detachDebugger(): void { + if (this.debuggerMessageHandler) { + this.webContents.debugger.removeListener('message', this.debuggerMessageHandler as never) + this.debuggerMessageHandler = null + } + if (this.debuggerDetachHandler) { + this.webContents.debugger.removeListener('detach', this.debuggerDetachHandler as never) + this.debuggerDetachHandler = null + } + const lease = this.debuggerLease + this.debuggerLease = null + lease?.release() + this.attached = false + } + + sendDebuggerCommand( + method: string, + params: Record, + sessionId?: string + ): Promise { + const command = sessionId + ? this.webContents.debugger.sendCommand(method, params, sessionId) + : this.webContents.debugger.sendCommand(method, params) + return Promise.resolve(command) + } + + forwardCommand( + client: WebSocket, + clientId: number, + method: string, + params: Record, + msgSessionId?: string + ): void { + if (this.webContents.isDestroyed()) { + this.responder.sendError(clientId, 'Browser tab is no longer available', client) + return + } + const sessionId = this.sessions.resolveDebuggerSessionId(msgSessionId) + try { + this.sendDebuggerCommand(method, params, sessionId) + .then((result) => { + this.responder.sendResult(clientId, result, client) + }) + .catch((err: Error) => { + this.responder.sendError(clientId, err.message, client) + }) + } catch (err) { + this.responder.sendError(clientId, err instanceof Error ? err.message : String(err), client) + } + } +} diff --git a/src/main/browser/cdp-dom-focus-replay.ts b/src/main/browser/cdp-dom-focus-replay.ts new file mode 100644 index 00000000000..517f7d48ce3 --- /dev/null +++ b/src/main/browser/cdp-dom-focus-replay.ts @@ -0,0 +1,116 @@ +import type { WebSocket } from 'ws' +import type { WebContents } from 'electron' +import type { CdpClientResponseWriter } from './cdp-client-response-writer' +import type { CdpDebuggerChannel } from './cdp-debugger-channel' + +/** + * Replays the last DOM.focus immediately before Input.insertText so the native + * webContents.focus() cannot blur the element the client meant to type into. + */ +export class CdpDomFocusReplay { + // Why: holds each session's last DOM.focus params to replay right before the next + // Input.insertText, countering the native webContents.focus() that would blur the target. + private pendingDomFocusBySession = new Map< + string | undefined, + Promise | undefined> + >() + + constructor( + private readonly webContents: WebContents, + private readonly responder: CdpClientResponseWriter, + private readonly debuggerChannel: CdpDebuggerChannel + ) {} + + clear(): void { + this.pendingDomFocusBySession.clear() + } + + invalidateForMethod(method: string, effectiveSessionId?: string): void { + // Why: a stored focus is only valid for the immediately following Input.insertText; + // any other command may have moved DOM focus, so invalidate the replay in one place. + if (method !== 'DOM.focus' && method !== 'Input.insertText') { + this.pendingDomFocusBySession.delete(effectiveSessionId) + } + } + + // Why: this must stay synchronous up to the `.set()` call so the pending-focus + // entry exists before the event loop can dispatch a pipelined Input.insertText + // message, closing the race where the replay would otherwise be silently skipped. + forwardDomFocus( + client: WebSocket, + clientId: number, + params: Record, + effectiveSessionId?: string + ): void { + const focused = this.sendDomFocus(client, clientId, params, effectiveSessionId) + this.pendingDomFocusBySession.set(effectiveSessionId, focused) + } + + private async sendDomFocus( + client: WebSocket, + clientId: number, + params: Record, + effectiveSessionId?: string + ): Promise | undefined> { + if (this.webContents.isDestroyed()) { + this.responder.sendError(clientId, 'Browser tab is no longer available', client) + return undefined + } + try { + const result = await this.debuggerChannel.sendDebuggerCommand( + 'DOM.focus', + params, + effectiveSessionId + ) + this.responder.sendResult(clientId, result, client) + return { ...params } + } catch (err) { + this.responder.sendError(clientId, err instanceof Error ? err.message : String(err), client) + return undefined + } + } + + async forwardInsertText( + client: WebSocket, + clientId: number, + params: Record, + effectiveSessionId?: string + ): Promise { + const pendingFocus = this.pendingDomFocusBySession.get(effectiveSessionId) + this.pendingDomFocusBySession.delete(effectiveSessionId) + const pendingFocusParams = pendingFocus ? await pendingFocus : undefined + // Why: the client can disconnect while DOM.focus is in flight; don't replay its + // focus or forward its insert into the live page once it is no longer active. + if (!this.responder.isActiveClient(client)) { + return + } + if (pendingFocusParams) { + if (this.webContents.isDestroyed()) { + this.responder.sendError(clientId, 'Browser tab is no longer available', client) + return + } + try { + await this.debuggerChannel.sendDebuggerCommand( + 'DOM.focus', + pendingFocusParams, + effectiveSessionId + ) + } catch (err) { + this.responder.sendError(clientId, err instanceof Error ? err.message : String(err), client) + return + } + // Why: the replay DOM.focus also awaited a round-trip; bail if the client vanished + // during it so its insert never lands in the live page. + if (!this.responder.isActiveClient(client)) { + return + } + } + this.debuggerChannel.forwardCommand( + client, + clientId, + 'Input.insertText', + params, + effectiveSessionId + ) + } +} diff --git a/src/main/browser/cdp-page-capture-commands.ts b/src/main/browser/cdp-page-capture-commands.ts new file mode 100644 index 00000000000..599caffb0ff --- /dev/null +++ b/src/main/browser/cdp-page-capture-commands.ts @@ -0,0 +1,82 @@ +import type { WebSocket } from 'ws' +import type { WebContents } from 'electron' +import { captureScreenshot } from './cdp-screenshot' +import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf' +import type { CdpClientResponseWriter } from './cdp-client-response-writer' + +/** + * Page capture output: Electron-native Page.printToPDF (inline and ReturnAsStream), + * the IO.read/IO.close handlers serving those PDF handles, and Page.captureScreenshot. + */ +export class CdpPageCaptureCommands { + private readonly pdfStreams = new CdpPdfStreamStore() + + constructor( + private readonly webContents: WebContents, + private readonly responder: CdpClientResponseWriter + ) {} + + clear(): void { + this.pdfStreams.clear() + } + + ownsHandle(params: Record): boolean { + return this.pdfStreams.ownsHandle(params) + } + + async handlePrintToPdf( + client: WebSocket, + clientId: number, + params: Record + ): Promise { + if (this.webContents.isDestroyed()) { + this.responder.sendError(clientId, 'Browser tab is no longer available', client) + return + } + try { + const pdf = await this.webContents.printToPDF(buildPrintToPdfOptions(params)) + // Why: printToPDF can resolve after the client disconnected (or was + // replaced). Bail before registering a stream so its buffer isn't + // orphaned in pdfStreams past the disconnect's clear() until the TTL. + if (!this.responder.isActiveClient(client)) { + return + } + const buffer = Buffer.isBuffer(pdf) ? pdf : Buffer.from(pdf) + if (params.transferMode === 'ReturnAsStream') { + const handle = this.pdfStreams.create(buffer) + this.responder.sendResult(clientId, { data: '', stream: handle }, client) + return + } + this.responder.sendResult(clientId, { data: buffer.toString('base64') }, client) + } catch (err) { + this.responder.sendError(clientId, err instanceof Error ? err.message : String(err), client) + } + } + + handleStreamRead(client: WebSocket, clientId: number, params: Record): void { + const chunk = this.pdfStreams.read(params) + if (!chunk) { + this.responder.sendError(clientId, 'Invalid stream handle', client) + return + } + this.responder.sendResult( + clientId, + { base64Encoded: true, data: chunk.data, eof: chunk.eof }, + client + ) + } + + handleStreamClose(client: WebSocket, clientId: number, params: Record): void { + this.pdfStreams.close(params) + this.responder.sendResult(clientId, {}, client) + } + + handleScreenshot(client: WebSocket, clientId: number, params?: Record): void { + captureScreenshot( + this.webContents, + params, + (result) => this.responder.sendResult(clientId, result, client), + (message) => this.responder.sendError(clientId, message, client) + ) + } +} diff --git a/src/main/browser/cdp-page-navigation-commands.ts b/src/main/browser/cdp-page-navigation-commands.ts new file mode 100644 index 00000000000..155f9fbaeda --- /dev/null +++ b/src/main/browser/cdp-page-navigation-commands.ts @@ -0,0 +1,105 @@ +import type { WebSocket } from 'ws' +import type { WebContents } from 'electron' +import type { CdpClientResponseWriter } from './cdp-client-response-writer' +import type { CdpSyntheticSessionRegistry } from './cdp-synthetic-session-registry' +import type { CdpDebuggerChannel } from './cdp-debugger-channel' + +const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000 + +/** + * Page.navigate and Page.reload for Electron webview guests: CDP subscriptions + * silently lapse across process swaps, so both are primed first (#7031). + */ +export class CdpPageNavigationCommands { + constructor( + private readonly webContents: WebContents, + private readonly responder: CdpClientResponseWriter, + private readonly sessions: CdpSyntheticSessionRegistry, + private readonly debuggerChannel: CdpDebuggerChannel + ) {} + + async navigateWithLifecycle( + client: WebSocket, + clientId: number, + params: Record, + msgSessionId?: string + ): Promise { + await this.primePageLifecycle(this.sessions.resolveDebuggerSessionId(msgSessionId)) + if (!this.responder.isActiveClient(client)) { + return + } + this.debuggerChannel.forwardCommand(client, clientId, 'Page.navigate', params, msgSessionId) + } + + async reloadWithLifecycle( + client: WebSocket, + clientId: number, + params: Record, + msgSessionId?: string + ): Promise { + const sessionId = this.sessions.resolveDebuggerSessionId(msgSessionId) + const unsupportedParam = sessionId ? null : this.getUnsupportedRootReloadParam(params) + if (unsupportedParam) { + this.responder.sendError( + clientId, + `Page.reload parameter "${unsupportedParam}" is not supported for Orca tab reloads`, + client + ) + return + } + await this.primePageLifecycle(sessionId) + if (!this.responder.isActiveClient(client)) { + return + } + if (sessionId) { + this.debuggerChannel.forwardCommand(client, clientId, 'Page.reload', params, msgSessionId) + return + } + if (this.webContents.isDestroyed()) { + this.responder.sendError(clientId, 'Browser tab is no longer available', client) + return + } + try { + if (params.ignoreCache === true) { + this.webContents.reloadIgnoringCache() + } else { + this.webContents.reload() + } + this.responder.sendResult(clientId, {}, client) + } catch (err) { + this.responder.sendError(clientId, err instanceof Error ? err.message : String(err), client) + } + } + + private getUnsupportedRootReloadParam(params: Record): string | null { + return Object.keys(params).find((key) => key !== 'ignoreCache') ?? null + } + + private async primePageLifecycle(sessionId?: string): Promise { + let timeout: ReturnType | null = null + const priming = (async (): Promise => { + // Why: without Network.enable, agent-browser never sees network idle → goto times out. + await this.debuggerChannel.sendDebuggerCommand('Network.enable', {}, sessionId) + await this.debuggerChannel.sendDebuggerCommand('Page.enable', {}, sessionId) + await this.debuggerChannel.sendDebuggerCommand( + 'Page.setLifecycleEventsEnabled', + { enabled: true }, + sessionId + ) + })().catch(() => {}) + + try { + await Promise.race([ + priming, + new Promise((resolve) => { + timeout = setTimeout(resolve, LIFECYCLE_PRIMING_TIMEOUT_MS) + timeout.unref?.() + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } + } +} diff --git a/src/main/browser/cdp-synthetic-session-registry.ts b/src/main/browser/cdp-synthetic-session-registry.ts new file mode 100644 index 00000000000..dba35349739 --- /dev/null +++ b/src/main/browser/cdp-synthetic-session-registry.ts @@ -0,0 +1,69 @@ +/** + * In-memory model of the synthetic session ids minted for Target.attachToTarget, + * and resolution of a client sessionId back to a real debugger sessionId. + */ +export class CdpSyntheticSessionRegistry { + // Why: agent-browser filters events by sessionId from Target.attachToTarget. + private clientSessionId: string | undefined = undefined + private readonly clientSessionIds = new Set() + private readonly clientBrowserSessionIds = new Set() + private nextClientSessionOrdinal = 0 + private nextClientBrowserSessionOrdinal = 0 + + get primarySessionId(): string | undefined { + return this.clientSessionId + } + + clear(): void { + this.clientSessionId = undefined + this.clientSessionIds.clear() + this.clientBrowserSessionIds.clear() + this.nextClientSessionOrdinal = 0 + this.nextClientBrowserSessionOrdinal = 0 + } + + attachPageSession(): string { + const sessionId = this.nextSyntheticPageSessionId() + this.clientSessionIds.add(sessionId) + this.clientSessionId ??= sessionId + return sessionId + } + + attachBrowserSession(): string { + // Why: Playwright needs a distinct browser session before it attaches to the selected page. + const sessionId = this.nextSyntheticBrowserSessionId() + this.clientBrowserSessionIds.add(sessionId) + return sessionId + } + + detachSession(detachedSessionId: unknown): void { + if (typeof detachedSessionId === 'string') { + this.clientSessionIds.delete(detachedSessionId) + this.clientBrowserSessionIds.delete(detachedSessionId) + if (detachedSessionId === this.clientSessionId) { + this.clientSessionId = this.clientSessionIds.values().next().value + } + } + } + + resolveDebuggerSessionId(msgSessionId?: string): string | undefined { + const syntheticSession = + (msgSessionId && this.clientSessionIds.has(msgSessionId)) || + (msgSessionId && this.clientBrowserSessionIds.has(msgSessionId)) + return msgSessionId && !syntheticSession ? msgSessionId : undefined + } + + private nextSyntheticPageSessionId(): string { + this.nextClientSessionOrdinal += 1 + return this.nextClientSessionOrdinal === 1 + ? 'orca-proxy-session' + : `orca-proxy-session-${this.nextClientSessionOrdinal}` + } + + private nextSyntheticBrowserSessionId(): string { + this.nextClientBrowserSessionOrdinal += 1 + return this.nextClientBrowserSessionOrdinal === 1 + ? 'orca-proxy-browser-session' + : `orca-proxy-browser-session-${this.nextClientBrowserSessionOrdinal}` + } +} diff --git a/src/main/browser/cdp-target-discovery.ts b/src/main/browser/cdp-target-discovery.ts new file mode 100644 index 00000000000..eaf34ffb16b --- /dev/null +++ b/src/main/browser/cdp-target-discovery.ts @@ -0,0 +1,117 @@ +import type { WebSocket } from 'ws' +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { WebContents } from 'electron' +import type { CdpClientResponseWriter } from './cdp-client-response-writer' +import type { CdpSyntheticSessionRegistry } from './cdp-synthetic-session-registry' + +/** + * The Chrome-lookalike identity surface: HTTP discovery endpoints and the + * Target/Browser commands answered from local state without the real debugger. + */ +export class CdpTargetDiscovery { + constructor( + private readonly webContents: WebContents, + private readonly responder: CdpClientResponseWriter, + private readonly sessions: CdpSyntheticSessionRegistry, + private readonly getPort: () => number + ) {} + + handleHttpRequest(req: IncomingMessage, res: ServerResponse): void { + const url = req.url ?? '' + if (url === '/json/version' || url === '/json/version/') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + // Why: agent-browser reads this endpoint to identify the browser. Returning + // "Orca/CdpWsProxy" leaks that this is an embedded automation surface, which + // could affect downstream detection heuristics. + // Why: process.versions.chrome contains the exact Chromium version + // bundled with Electron, producing a realistic version string. + const chromeVersion = process.versions.chrome ?? '134.0.0.0' + res.end( + JSON.stringify({ + Browser: `Chrome/${chromeVersion}`, + 'Protocol-Version': '1.3', + webSocketDebuggerUrl: `ws://127.0.0.1:${this.getPort()}` + }) + ) + return + } + if (url === '/json' || url === '/json/' || url === '/json/list' || url === '/json/list/') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify([ + { + ...this.buildTargetInfo(), + id: 'orca-proxy-target', + webSocketDebuggerUrl: `ws://127.0.0.1:${this.getPort()}` + } + ]) + ) + return + } + res.writeHead(404) + res.end() + } + + /** Returns true when the command was answered locally and needs no forwarding. */ + handleCommand( + client: WebSocket, + clientId: number, + msg: { method?: string; params?: Record } + ): boolean { + if (msg.method === 'Target.getTargets') { + this.responder.sendResult(clientId, { targetInfos: [this.buildTargetInfo()] }, client) + return true + } + if (msg.method === 'Target.getTargetInfo') { + this.responder.sendResult(clientId, { targetInfo: this.buildTargetInfo() }, client) + return true + } + if (msg.method === 'Target.setDiscoverTargets' || msg.method === 'Target.detachFromTarget') { + if (msg.method === 'Target.detachFromTarget') { + const detachedSessionId = msg.params?.sessionId + this.sessions.detachSession(detachedSessionId) + } + this.responder.sendResult(clientId, {}, client) + return true + } + if (msg.method === 'Target.attachToBrowserTarget') { + const sessionId = this.sessions.attachBrowserSession() + this.responder.sendResult(clientId, { sessionId }, client) + return true + } + if (msg.method === 'Target.attachToTarget') { + const sessionId = this.sessions.attachPageSession() + this.responder.sendResult(clientId, { sessionId }, client) + return true + } + if (msg.method === 'Browser.getVersion') { + // Why: returning "Orca/Electron" identifies this as an embedded automation + // surface to agent-browser. Use a generic Chrome product string instead. + const chromeVersion = process.versions.chrome ?? '134.0.0.0' + this.responder.sendResult( + clientId, + { + protocolVersion: '1.3', + product: `Chrome/${chromeVersion}`, + userAgent: '', + jsVersion: '' + }, + client + ) + return true + } + return false + } + + private buildTargetInfo(): Record { + const destroyed = this.webContents.isDestroyed() + return { + targetId: 'orca-proxy-target', + type: 'page', + title: destroyed ? '' : this.webContents.getTitle(), + url: destroyed ? '' : this.webContents.getURL(), + attached: true, + canAccessOpener: false + } + } +} diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index 5c366eda727..142e3832be2 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -721,7 +721,9 @@ describe('CdpWsProxy', () => { resolvePrint = resolve }) ) - const store = (proxy as unknown as { pdfStreams: { create: (b: Buffer) => string } }).pdfStreams + const store = ( + proxy as unknown as { pageCapture: { pdfStreams: { create: (b: Buffer) => string } } } + ).pageCapture.pdfStreams const createSpy = vi.spyOn(store, 'create') const client = await connect(endpoint) diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index 99c1d554654..32ea35be12c 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -1,45 +1,56 @@ -/* eslint-disable max-lines -- Why: this proxy owns HTTP discovery, websocket client lifecycle, and CDP debugger forwarding together. */ -import { WebSocketServer, WebSocket } from 'ws' -import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http' +import { WebSocketServer, type WebSocket } from 'ws' +import { createServer, type Server } from 'node:http' import type { WebContents } from 'electron' -import { captureScreenshot } from './cdp-screenshot' -import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf' -import { ANTI_DETECTION_SCRIPT } from './anti-detection' -import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease' - -const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000 +import { CdpClientResponseWriter } from './cdp-client-response-writer' +import { CdpSyntheticSessionRegistry } from './cdp-synthetic-session-registry' +import { CdpTargetDiscovery } from './cdp-target-discovery' +import { CdpDebuggerChannel } from './cdp-debugger-channel' +import { CdpPageNavigationCommands } from './cdp-page-navigation-commands' +import { CdpDomFocusReplay } from './cdp-dom-focus-replay' +import { CdpPageCaptureCommands } from './cdp-page-capture-commands' export class CdpWsProxy { - // Why: holds each session's last DOM.focus params to replay right before the next - // Input.insertText, countering the native webContents.focus() that would blur the target. - private pendingDomFocusBySession = new Map< - string | undefined, - Promise | undefined> - >() private httpServer: Server | null = null private wss: WebSocketServer | null = null private client: WebSocket | null = null - private readonly responseSessionIdsByClient = new WeakMap>() private detachClientListeners: (() => void) | null = null private port = 0 - private debuggerMessageHandler: ((...args: unknown[]) => void) | null = null - private debuggerDetachHandler: ((...args: unknown[]) => void) | null = null - private debuggerLease: ElectronDebuggerLease | null = null - private attached = false - // Why: agent-browser filters events by sessionId from Target.attachToTarget. - private clientSessionId: string | undefined = undefined - private readonly clientSessionIds = new Set() - private readonly clientBrowserSessionIds = new Set() - private nextClientSessionOrdinal = 0 - private nextClientBrowserSessionOrdinal = 0 - private readonly pdfStreams = new CdpPdfStreamStore() + private readonly responder = new CdpClientResponseWriter(() => this.client) + private readonly sessions = new CdpSyntheticSessionRegistry() + private readonly discovery: CdpTargetDiscovery + private readonly debuggerChannel: CdpDebuggerChannel + private readonly navigation: CdpPageNavigationCommands + private readonly domFocusReplay: CdpDomFocusReplay + private readonly pageCapture: CdpPageCaptureCommands - constructor(private readonly webContents: WebContents) {} + constructor(private readonly webContents: WebContents) { + this.discovery = new CdpTargetDiscovery( + webContents, + this.responder, + this.sessions, + () => this.port + ) + this.debuggerChannel = new CdpDebuggerChannel( + webContents, + this.responder, + this.sessions, + () => this.client, + () => this.stop() + ) + this.navigation = new CdpPageNavigationCommands( + webContents, + this.responder, + this.sessions, + this.debuggerChannel + ) + this.domFocusReplay = new CdpDomFocusReplay(webContents, this.responder, this.debuggerChannel) + this.pageCapture = new CdpPageCaptureCommands(webContents, this.responder) + } async start(): Promise { - await this.attachDebugger() + await this.debuggerChannel.attachDebugger() return new Promise((resolve, reject) => { - this.httpServer = createServer((req, res) => this.handleHttpRequest(req, res)) + this.httpServer = createServer((req, res) => this.discovery.handleHttpRequest(req, res)) this.wss = new WebSocketServer({ server: this.httpServer }) const failStart = (error: Error): void => { this.httpServer?.removeListener('error', onListenError) @@ -49,7 +60,7 @@ export class CdpWsProxy { this.httpServer = null // Why: a bind failure happens after debugger attach; release it here // because callers cannot safely call stop() on a failed start. - this.detachDebugger() + this.debuggerChannel.detachDebugger() reject(error) } const onListenError = (error: Error): void => { @@ -94,7 +105,7 @@ export class CdpWsProxy { } async stop(): Promise { - this.detachDebugger() + this.debuggerChannel.detachDebugger() this.closeClient() if (this.wss) { this.wss.close() @@ -117,161 +128,16 @@ export class CdpWsProxy { this.client = null this.clearClientState() if (client) { - this.responseSessionIdsByClient.delete(client) + this.responder.forgetClient(client) } client?.close() } private clearClientState(): void { // Why: session and focus state belongs to one websocket and must not cross client replacement. - this.pendingDomFocusBySession.clear() - this.pdfStreams.clear() - this.clientSessionId = undefined - this.clientSessionIds.clear() - this.clientBrowserSessionIds.clear() - this.nextClientSessionOrdinal = 0 - this.nextClientBrowserSessionOrdinal = 0 - } - - private send(payload: unknown, client = this.client): void { - const responsePayload = client ? this.addResponseSessionId(payload, client) : payload - if (client?.readyState === WebSocket.OPEN) { - client.send(JSON.stringify(responsePayload)) - } - } - - private addResponseSessionId(payload: unknown, client: WebSocket): unknown { - if (typeof payload !== 'object' || payload === null) { - return payload - } - const clientId = (payload as { id?: unknown }).id - if (typeof clientId !== 'number') { - return payload - } - const responseSessionIds = this.responseSessionIdsByClient.get(client) - const sessionId = responseSessionIds?.get(clientId) - responseSessionIds?.delete(clientId) - return sessionId ? { ...payload, sessionId } : payload - } - - private sendResult(clientId: number, result: unknown, client = this.client): void { - this.send({ id: clientId, result }, client) - } - - private sendError(clientId: number, message: string, client = this.client): void { - this.send({ id: clientId, error: { code: -32000, message } }, client) - } - - private buildTargetInfo(): Record { - const destroyed = this.webContents.isDestroyed() - return { - targetId: 'orca-proxy-target', - type: 'page', - title: destroyed ? '' : this.webContents.getTitle(), - url: destroyed ? '' : this.webContents.getURL(), - attached: true, - canAccessOpener: false - } - } - - private handleHttpRequest(req: IncomingMessage, res: ServerResponse): void { - const url = req.url ?? '' - if (url === '/json/version' || url === '/json/version/') { - res.writeHead(200, { 'Content-Type': 'application/json' }) - // Why: agent-browser reads this endpoint to identify the browser. Returning - // "Orca/CdpWsProxy" leaks that this is an embedded automation surface, which - // could affect downstream detection heuristics. - // Why: process.versions.chrome contains the exact Chromium version - // bundled with Electron, producing a realistic version string. - const chromeVersion = process.versions.chrome ?? '134.0.0.0' - res.end( - JSON.stringify({ - Browser: `Chrome/${chromeVersion}`, - 'Protocol-Version': '1.3', - webSocketDebuggerUrl: `ws://127.0.0.1:${this.port}` - }) - ) - return - } - if (url === '/json' || url === '/json/' || url === '/json/list' || url === '/json/list/') { - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end( - JSON.stringify([ - { - ...this.buildTargetInfo(), - id: 'orca-proxy-target', - webSocketDebuggerUrl: `ws://127.0.0.1:${this.port}` - } - ]) - ) - return - } - res.writeHead(404) - res.end() - } - - private async attachDebugger(): Promise { - if (this.attached) { - return - } - try { - this.debuggerLease = acquireElectronDebugger(this.webContents) - } catch { - throw new Error('Could not attach debugger. DevTools may already be open for this tab.') - } - this.attached = true - - // Why: attaching the CDP debugger sets navigator.webdriver = true and - // exposes other automation signals that Cloudflare Turnstile checks. - // Inject before any page loads so challenges succeed. - try { - await this.webContents.debugger.sendCommand('Page.enable', {}) - await this.webContents.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', { - source: ANTI_DETECTION_SCRIPT - }) - } catch { - /* best-effort — page domain may not be ready yet */ - } - - this.debuggerMessageHandler = (_event: unknown, ...rest: unknown[]) => { - const [method, params, sessionId] = rest as [ - string, - Record, - string | undefined - ] - if (!this.client || this.client.readyState !== WebSocket.OPEN) { - return - } - // Why: Electron passes empty string (not undefined) for root-session events, but - // agent-browser filters events by the sessionId from Target.attachToTarget. - const msg: Record = { method, params } - msg.sessionId = sessionId || this.clientSessionId - this.client.send(JSON.stringify(msg)) - } - this.debuggerDetachHandler = () => { - this.attached = false - const lease = this.debuggerLease - this.debuggerLease = null - lease?.release() - this.stop() - } - this.webContents.debugger.on('message', this.debuggerMessageHandler as never) - this.webContents.debugger.on('detach', this.debuggerDetachHandler as never) - } - - private detachDebugger(): void { - if (this.debuggerMessageHandler) { - this.webContents.debugger.removeListener('message', this.debuggerMessageHandler as never) - this.debuggerMessageHandler = null - } - if (this.debuggerDetachHandler) { - this.webContents.debugger.removeListener('detach', this.debuggerDetachHandler as never) - this.debuggerDetachHandler = null - } - const lease = this.debuggerLease - this.debuggerLease = null - lease?.release() - this.attached = false + this.domFocusReplay.clear() + this.pageCapture.clear() + this.sessions.clear() } private handleClientMessage(client: WebSocket, raw: string): void { @@ -285,110 +151,51 @@ export class CdpWsProxy { return } const clientId = msg.id - const responseSessionIds = this.responseSessionIdsByClient.get(client) ?? new Map() - if (msg.sessionId) { - responseSessionIds.set(clientId, msg.sessionId) - } else { - responseSessionIds.delete(clientId) - } - this.responseSessionIdsByClient.set(client, responseSessionIds) + this.responder.recordRequestSessionId(client, clientId, msg) - if (msg.method === 'Target.getTargets') { - this.sendResult(clientId, { targetInfos: [this.buildTargetInfo()] }, client) + if (this.discovery.handleCommand(client, clientId, msg)) { return } - if (msg.method === 'Target.getTargetInfo') { - this.sendResult(clientId, { targetInfo: this.buildTargetInfo() }, client) - return - } - if (msg.method === 'Target.setDiscoverTargets' || msg.method === 'Target.detachFromTarget') { - if (msg.method === 'Target.detachFromTarget') { - const detachedSessionId = msg.params?.sessionId - if (typeof detachedSessionId === 'string') { - this.clientSessionIds.delete(detachedSessionId) - this.clientBrowserSessionIds.delete(detachedSessionId) - if (detachedSessionId === this.clientSessionId) { - this.clientSessionId = this.clientSessionIds.values().next().value - } - } - } - this.sendResult(clientId, {}, client) - return - } - if (msg.method === 'Target.attachToBrowserTarget') { - // Why: Playwright needs a distinct browser session before it attaches to the selected page. - const sessionId = this.nextSyntheticBrowserSessionId() - this.clientBrowserSessionIds.add(sessionId) - this.sendResult(clientId, { sessionId }, client) - return - } - if (msg.method === 'Target.attachToTarget') { - const sessionId = this.nextSyntheticPageSessionId() - this.clientSessionIds.add(sessionId) - this.clientSessionId ??= sessionId - this.sendResult(clientId, { sessionId }, client) - return - } - if (msg.method === 'Browser.getVersion') { - // Why: returning "Orca/Electron" identifies this as an embedded automation - // surface to agent-browser. Use a generic Chrome product string instead. - const chromeVersion = process.versions.chrome ?? '134.0.0.0' - this.sendResult( - clientId, - { - protocolVersion: '1.3', - product: `Chrome/${chromeVersion}`, - userAgent: '', - jsVersion: '' - }, - client - ) - return - } - const effectiveSessionId = this.resolveDebuggerSessionId(msg.sessionId) - // Why: a stored focus is only valid for the immediately following Input.insertText; - // any other command may have moved DOM focus, so invalidate the replay in one place. - if (msg.method !== 'DOM.focus' && msg.method !== 'Input.insertText') { - this.pendingDomFocusBySession.delete(effectiveSessionId) - } + const effectiveSessionId = this.sessions.resolveDebuggerSessionId(msg.sessionId) + this.domFocusReplay.invalidateForMethod(msg.method, effectiveSessionId) if (msg.method === 'Page.bringToFront') { if (!this.webContents.isDestroyed()) { this.webContents.focus() } - this.sendResult(clientId, {}, client) + this.responder.sendResult(clientId, {}, client) return } if (msg.method === 'DOM.focus') { - this.forwardDomFocus(client, clientId, msg.params ?? {}, effectiveSessionId) + this.domFocusReplay.forwardDomFocus(client, clientId, msg.params ?? {}, effectiveSessionId) return } // Why: Page.captureScreenshot via debugger.sendCommand hangs on Electron webview guests. if (msg.method === 'Page.captureScreenshot') { - this.handleScreenshot(client, clientId, msg.params) + this.pageCapture.handleScreenshot(client, clientId, msg.params) return } // Why: CDP Page.printToPDF is not available for Electron webview guests. // Electron's native printToPDF path is the reliable equivalent. if (msg.method === 'Page.printToPDF') { - void this.handlePrintToPdf(client, clientId, msg.params ?? {}) + void this.pageCapture.handlePrintToPdf(client, clientId, msg.params ?? {}) return } if (msg.method === 'IO.read') { const params = msg.params ?? {} - if (this.pdfStreams.ownsHandle(params)) { - this.handleStreamRead(client, clientId, params) + if (this.pageCapture.ownsHandle(params)) { + this.pageCapture.handleStreamRead(client, clientId, params) return } - this.forwardCommand(client, clientId, msg.method, params, msg.sessionId) + this.debuggerChannel.forwardCommand(client, clientId, msg.method, params, msg.sessionId) return } if (msg.method === 'IO.close') { const params = msg.params ?? {} - if (this.pdfStreams.ownsHandle(params)) { - this.handleStreamClose(client, clientId, params) + if (this.pageCapture.ownsHandle(params)) { + this.pageCapture.handleStreamClose(client, clientId, params) return } - this.forwardCommand(client, clientId, msg.method, params, msg.sessionId) + this.debuggerChannel.forwardCommand(client, clientId, msg.method, params, msg.sessionId) return } // Why: Input.insertText can still require native focus in Electron webviews. @@ -398,295 +205,33 @@ export class CdpWsProxy { // is running. if (msg.method === 'Input.insertText' && !this.webContents.isDestroyed()) { this.webContents.focus() - void this.forwardInsertText(client, clientId, msg.params ?? {}, effectiveSessionId) + void this.domFocusReplay.forwardInsertText( + client, + clientId, + msg.params ?? {}, + effectiveSessionId + ) return } // Why: agent-browser waits for network idle to detect navigation completion. // Electron webview CDP subscriptions silently lapse after cross-process swaps. // Page.reload needs the same priming: forwarding it unprimed closed the tab (#7031). if (msg.method === 'Page.navigate' && !this.webContents.isDestroyed()) { - void this.navigateWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId) + void this.navigation.navigateWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId) return } // Why: CDP Page.reload can destroy Electron webview targets during process swaps. // Use the same direct webContents reload path as Orca's own browser.reload. if (msg.method === 'Page.reload' && !this.webContents.isDestroyed()) { - void this.reloadWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId) + void this.navigation.reloadWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId) return } - this.forwardCommand(client, clientId, msg.method, msg.params ?? {}, msg.sessionId) - } - - private resolveDebuggerSessionId(msgSessionId?: string): string | undefined { - const syntheticSession = - (msgSessionId && this.clientSessionIds.has(msgSessionId)) || - (msgSessionId && this.clientBrowserSessionIds.has(msgSessionId)) - return msgSessionId && !syntheticSession ? msgSessionId : undefined - } - - private nextSyntheticPageSessionId(): string { - this.nextClientSessionOrdinal += 1 - return this.nextClientSessionOrdinal === 1 - ? 'orca-proxy-session' - : `orca-proxy-session-${this.nextClientSessionOrdinal}` - } - - private nextSyntheticBrowserSessionId(): string { - this.nextClientBrowserSessionOrdinal += 1 - return this.nextClientBrowserSessionOrdinal === 1 - ? 'orca-proxy-browser-session' - : `orca-proxy-browser-session-${this.nextClientBrowserSessionOrdinal}` - } - - private isActiveClient(client: WebSocket): boolean { - return this.client === client && client.readyState === WebSocket.OPEN - } - - private sendDebuggerCommand( - method: string, - params: Record, - sessionId?: string - ): Promise { - const command = sessionId - ? this.webContents.debugger.sendCommand(method, params, sessionId) - : this.webContents.debugger.sendCommand(method, params) - return Promise.resolve(command) - } - - private forwardCommand( - client: WebSocket, - clientId: number, - method: string, - params: Record, - msgSessionId?: string - ): void { - if (this.webContents.isDestroyed()) { - this.sendError(clientId, 'Browser tab is no longer available', client) - return - } - const sessionId = this.resolveDebuggerSessionId(msgSessionId) - try { - this.sendDebuggerCommand(method, params, sessionId) - .then((result) => { - this.sendResult(clientId, result, client) - }) - .catch((err: Error) => { - this.sendError(clientId, err.message, client) - }) - } catch (err) { - this.sendError(clientId, err instanceof Error ? err.message : String(err), client) - } - } - - private async navigateWithLifecycle( - client: WebSocket, - clientId: number, - params: Record, - msgSessionId?: string - ): Promise { - await this.primePageLifecycle(this.resolveDebuggerSessionId(msgSessionId)) - if (!this.isActiveClient(client)) { - return - } - this.forwardCommand(client, clientId, 'Page.navigate', params, msgSessionId) - } - - private async reloadWithLifecycle( - client: WebSocket, - clientId: number, - params: Record, - msgSessionId?: string - ): Promise { - const sessionId = this.resolveDebuggerSessionId(msgSessionId) - const unsupportedParam = sessionId ? null : this.getUnsupportedRootReloadParam(params) - if (unsupportedParam) { - this.sendError( - clientId, - `Page.reload parameter "${unsupportedParam}" is not supported for Orca tab reloads`, - client - ) - return - } - await this.primePageLifecycle(sessionId) - if (!this.isActiveClient(client)) { - return - } - if (sessionId) { - this.forwardCommand(client, clientId, 'Page.reload', params, msgSessionId) - return - } - if (this.webContents.isDestroyed()) { - this.sendError(clientId, 'Browser tab is no longer available', client) - return - } - try { - if (params.ignoreCache === true) { - this.webContents.reloadIgnoringCache() - } else { - this.webContents.reload() - } - this.sendResult(clientId, {}, client) - } catch (err) { - this.sendError(clientId, err instanceof Error ? err.message : String(err), client) - } - } - - private getUnsupportedRootReloadParam(params: Record): string | null { - return Object.keys(params).find((key) => key !== 'ignoreCache') ?? null - } - - private async primePageLifecycle(sessionId?: string): Promise { - let timeout: ReturnType | null = null - const priming = (async (): Promise => { - // Why: without Network.enable, agent-browser never sees network idle → goto times out. - await this.sendDebuggerCommand('Network.enable', {}, sessionId) - await this.sendDebuggerCommand('Page.enable', {}, sessionId) - await this.sendDebuggerCommand('Page.setLifecycleEventsEnabled', { enabled: true }, sessionId) - })().catch(() => {}) - - try { - await Promise.race([ - priming, - new Promise((resolve) => { - timeout = setTimeout(resolve, LIFECYCLE_PRIMING_TIMEOUT_MS) - timeout.unref?.() - }) - ]) - } finally { - if (timeout) { - clearTimeout(timeout) - } - } - } - - // Why: this must stay synchronous up to the `.set()` call so the pending-focus - // entry exists before the event loop can dispatch a pipelined Input.insertText - // message, closing the race where the replay would otherwise be silently skipped. - private forwardDomFocus( - client: WebSocket, - clientId: number, - params: Record, - effectiveSessionId?: string - ): void { - const focused = this.sendDomFocus(client, clientId, params, effectiveSessionId) - this.pendingDomFocusBySession.set(effectiveSessionId, focused) - } - - private async sendDomFocus( - client: WebSocket, - clientId: number, - params: Record, - effectiveSessionId?: string - ): Promise | undefined> { - if (this.webContents.isDestroyed()) { - this.sendError(clientId, 'Browser tab is no longer available', client) - return undefined - } - try { - const result = await this.sendDebuggerCommand('DOM.focus', params, effectiveSessionId) - this.sendResult(clientId, result, client) - return { ...params } - } catch (err) { - this.sendError(clientId, err instanceof Error ? err.message : String(err), client) - return undefined - } - } - - private async forwardInsertText( - client: WebSocket, - clientId: number, - params: Record, - effectiveSessionId?: string - ): Promise { - const pendingFocus = this.pendingDomFocusBySession.get(effectiveSessionId) - this.pendingDomFocusBySession.delete(effectiveSessionId) - const pendingFocusParams = pendingFocus ? await pendingFocus : undefined - // Why: the client can disconnect while DOM.focus is in flight; don't replay its - // focus or forward its insert into the live page once it is no longer active. - if (!this.isActiveClient(client)) { - return - } - if (pendingFocusParams) { - if (this.webContents.isDestroyed()) { - this.sendError(clientId, 'Browser tab is no longer available', client) - return - } - try { - await this.sendDebuggerCommand('DOM.focus', pendingFocusParams, effectiveSessionId) - } catch (err) { - this.sendError(clientId, err instanceof Error ? err.message : String(err), client) - return - } - // Why: the replay DOM.focus also awaited a round-trip; bail if the client vanished - // during it so its insert never lands in the live page. - if (!this.isActiveClient(client)) { - return - } - } - this.forwardCommand(client, clientId, 'Input.insertText', params, effectiveSessionId) - } - - private async handlePrintToPdf( - client: WebSocket, - clientId: number, - params: Record - ): Promise { - if (this.webContents.isDestroyed()) { - this.sendError(clientId, 'Browser tab is no longer available', client) - return - } - try { - const pdf = await this.webContents.printToPDF(buildPrintToPdfOptions(params)) - // Why: printToPDF can resolve after the client disconnected (or was - // replaced). Bail before registering a stream so its buffer isn't - // orphaned in pdfStreams past the disconnect's clear() until the TTL. - if (!this.isActiveClient(client)) { - return - } - const buffer = Buffer.isBuffer(pdf) ? pdf : Buffer.from(pdf) - if (params.transferMode === 'ReturnAsStream') { - const handle = this.pdfStreams.create(buffer) - this.sendResult(clientId, { data: '', stream: handle }, client) - return - } - this.sendResult(clientId, { data: buffer.toString('base64') }, client) - } catch (err) { - this.sendError(clientId, err instanceof Error ? err.message : String(err), client) - } - } - - private handleStreamRead( - client: WebSocket, - clientId: number, - params: Record - ): void { - const chunk = this.pdfStreams.read(params) - if (!chunk) { - this.sendError(clientId, 'Invalid stream handle', client) - return - } - this.sendResult(clientId, { base64Encoded: true, data: chunk.data, eof: chunk.eof }, client) - } - - private handleStreamClose( - client: WebSocket, - clientId: number, - params: Record - ): void { - this.pdfStreams.close(params) - this.sendResult(clientId, {}, client) - } - - private handleScreenshot( - client: WebSocket, - clientId: number, - params?: Record - ): void { - captureScreenshot( - this.webContents, - params, - (result) => this.sendResult(clientId, result, client), - (message) => this.sendError(clientId, message, client) + this.debuggerChannel.forwardCommand( + client, + clientId, + msg.method, + msg.params ?? {}, + msg.sessionId ) } } diff --git a/src/main/browser/snapshot-ax-tree-walk.ts b/src/main/browser/snapshot-ax-tree-walk.ts new file mode 100644 index 00000000000..0690e549699 --- /dev/null +++ b/src/main/browser/snapshot-ax-tree-walk.ts @@ -0,0 +1,208 @@ +export type AXNode = { + nodeId: string + backendDOMNodeId?: number + role?: { type: string; value: string } + name?: { type: string; value: string } + properties?: { name: string; value: { type: string; value: unknown } }[] + childIds?: string[] + ignored?: boolean +} + +export type SnapshotEntry = { + ref: string + role: string + name: string + backendDOMNodeId: number + depth: number +} + +const INTERACTIVE_ROLES = new Set([ + 'button', + 'link', + 'textbox', + 'searchbox', + 'combobox', + 'checkbox', + 'radio', + 'switch', + 'slider', + 'spinbutton', + 'menuitem', + 'menuitemcheckbox', + 'menuitemradio', + 'tab', + 'option', + 'treeitem' +]) + +const LANDMARK_ROLES = new Set([ + 'banner', + 'navigation', + 'main', + 'complementary', + 'contentinfo', + 'region', + 'form', + 'search' +]) + +const HEADING_PATTERN = /^heading$/ + +const SKIP_ROLES = new Set(['none', 'presentation', 'generic']) + +export function walkTree( + node: AXNode, + nodeById: Map, + depth: number, + entries: SnapshotEntry[], + nextRef: () => number +): void { + if (node.ignored) { + walkChildren(node, nodeById, depth, entries, nextRef) + return + } + + const role = node.role?.value ?? '' + const name = node.name?.value ?? '' + + if (SKIP_ROLES.has(role)) { + walkChildren(node, nodeById, depth, entries, nextRef) + return + } + + const isInteractive = INTERACTIVE_ROLES.has(role) + const isHeading = HEADING_PATTERN.test(role) + const isLandmark = LANDMARK_ROLES.has(role) + const isStaticText = role === 'staticText' || role === 'StaticText' + + if (!isInteractive && !isHeading && !isLandmark && !isStaticText) { + walkChildren(node, nodeById, depth, entries, nextRef) + return + } + + if (!name && !isLandmark) { + walkChildren(node, nodeById, depth, entries, nextRef) + return + } + + const hasFocusable = isInteractive && isFocusable(node) + + if (isLandmark) { + entries.push({ + ref: '', + role: formatLandmarkRole(role, name), + name: name || role, + backendDOMNodeId: node.backendDOMNodeId ?? 0, + depth + }) + walkChildren(node, nodeById, depth + 1, entries, nextRef) + return + } + + if (isHeading) { + entries.push({ + ref: '', + role: 'heading', + name, + backendDOMNodeId: node.backendDOMNodeId ?? 0, + depth + }) + return + } + + if (isStaticText && name.trim().length > 0) { + entries.push({ + ref: '', + role: 'text', + name: name.trim(), + backendDOMNodeId: node.backendDOMNodeId ?? 0, + depth + }) + return + } + + if (isInteractive && (hasFocusable || node.backendDOMNodeId)) { + const ref = `@e${nextRef()}` + entries.push({ + ref, + role: formatInteractiveRole(role), + name: name || '(unlabeled)', + backendDOMNodeId: node.backendDOMNodeId ?? 0, + depth + }) + return + } + + walkChildren(node, nodeById, depth, entries, nextRef) +} + +function walkChildren( + node: AXNode, + nodeById: Map, + depth: number, + entries: SnapshotEntry[], + nextRef: () => number +): void { + if (!node.childIds) { + return + } + for (const childId of node.childIds) { + const child = nodeById.get(childId) + if (child) { + walkTree(child, nodeById, depth, entries, nextRef) + } + } +} + +function isFocusable(node: AXNode): boolean { + if (!node.properties) { + return true + } + const focusable = node.properties.find((p) => p.name === 'focusable') + if (focusable && focusable.value.value === false) { + return false + } + return true +} + +function formatInteractiveRole(role: string): string { + switch (role) { + case 'textbox': + case 'searchbox': + return 'text input' + case 'combobox': + return 'combobox' + case 'menuitem': + case 'menuitemcheckbox': + case 'menuitemradio': + return 'menu item' + case 'spinbutton': + return 'number input' + case 'treeitem': + return 'tree item' + default: + return role + } +} + +function formatLandmarkRole(role: string, name: string): string { + if (name) { + return `[${name}]` + } + switch (role) { + case 'banner': + return '[Header]' + case 'navigation': + return '[Navigation]' + case 'main': + return '[Main Content]' + case 'complementary': + return '[Sidebar]' + case 'contentinfo': + return '[Footer]' + case 'search': + return '[Search]' + default: + return `[${role}]` + } +} diff --git a/src/main/browser/snapshot-cursor-interactive-elements.ts b/src/main/browser/snapshot-cursor-interactive-elements.ts new file mode 100644 index 00000000000..542293fc6ec --- /dev/null +++ b/src/main/browser/snapshot-cursor-interactive-elements.ts @@ -0,0 +1,102 @@ +import type { SnapshotEntry } from './snapshot-ax-tree-walk' +import type { CdpCommandSender } from './snapshot-engine' + +// Why: finds DOM elements that are visually interactive (cursor:pointer, onclick, +// tabindex, contenteditable) but lack standard ARIA roles. These are common in +// modern SPAs where styled
s act as buttons. Returns them as a JS array of +// remote object references that we can resolve to backendNodeIds via CDP. +export async function findCursorInteractiveElements( + sendCommand: CdpCommandSender, + existingEntries: SnapshotEntry[] +): Promise { + const existingNodeIds = new Set(existingEntries.map((e) => e.backendDOMNodeId)) + const results: SnapshotEntry[] = [] + + try { + // Single evaluate call that finds interactive elements and returns their info + // along with a way to reference them by index + const { result } = (await sendCommand('Runtime.evaluate', { + expression: `(() => { + const SKIP_ROLES = new Set(['button','link','textbox','checkbox','radio','tab', + 'menuitem','option','switch','slider','combobox','searchbox','spinbutton','treeitem', + 'menuitemcheckbox','menuitemradio']); + const SKIP_TAGS = new Set(['input','button','select','textarea','a']); + const seen = new Set(); + const found = []; + const matchedElements = []; + + function check(el) { + if (seen.has(el)) return; + seen.add(el); + const tag = el.tagName.toLowerCase(); + if (SKIP_TAGS.has(tag)) return; + const role = el.getAttribute('role'); + if (role && SKIP_ROLES.has(role)) return; + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + const text = (el.ariaLabel || el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 80); + if (!text) return; + found.push({ text, tag }); + matchedElements.push(el); + if (found.length >= 50) return; + } + + document.querySelectorAll('[onclick], [tabindex]:not([tabindex="-1"]), [contenteditable="true"]').forEach(el => { + if (found.length < 50) check(el); + }); + document.querySelectorAll('div, span, li, td, img, svg, label').forEach(el => { + if (found.length >= 50) return; + try { + if (window.getComputedStyle(el).cursor === 'pointer') check(el); + } catch {} + }); + + window.__orcaCursorInteractive = matchedElements; + return JSON.stringify(found); + })()`, + returnByValue: true + })) as { result: { value: string } } + + const elements = JSON.parse(result.value) as { text: string; tag: string }[] + + for (let i = 0; i < elements.length; i++) { + try { + const { result: objResult } = (await sendCommand('Runtime.evaluate', { + expression: `window.__orcaCursorInteractive[${i}]` + })) as { result: { objectId?: string } } + + if (!objResult.objectId) { + continue + } + + const { node } = (await sendCommand('DOM.describeNode', { + objectId: objResult.objectId + })) as { node: { backendNodeId: number } } + + if (existingNodeIds.has(node.backendNodeId)) { + continue + } + + results.push({ + ref: '', + role: 'clickable', + name: elements[i].text, + backendDOMNodeId: node.backendNodeId, + depth: 0 + }) + } catch { + continue + } + } + + // Clean up + await sendCommand('Runtime.evaluate', { + expression: 'delete window.__orcaCursorInteractive', + returnByValue: true + }) + } catch { + // DOM query failed — not critical, just return empty + } + + return results +} diff --git a/src/main/browser/snapshot-engine.ts b/src/main/browser/snapshot-engine.ts index 141a3d957dc..5cdabbc9375 100644 --- a/src/main/browser/snapshot-engine.ts +++ b/src/main/browser/snapshot-engine.ts @@ -1,29 +1,12 @@ -/* eslint-disable max-lines -- Why: snapshot building, AX tree walking, ref mapping, and cursor-interactive detection are tightly coupled and belong in one module. */ import type { BrowserSnapshotRef } from '../../shared/runtime-types' +import { walkTree, type AXNode, type SnapshotEntry } from './snapshot-ax-tree-walk' +import { findCursorInteractiveElements } from './snapshot-cursor-interactive-elements' export type CdpCommandSender = ( method: string, params?: Record ) => Promise -type AXNode = { - nodeId: string - backendDOMNodeId?: number - role?: { type: string; value: string } - name?: { type: string; value: string } - properties?: { name: string; value: { type: string; value: unknown } }[] - childIds?: string[] - ignored?: boolean -} - -type SnapshotEntry = { - ref: string - role: string - name: string - backendDOMNodeId: number - depth: number -} - export type RefEntry = { backendDOMNodeId: number role: string @@ -41,40 +24,6 @@ export type SnapshotResult = { refMap: Map } -const INTERACTIVE_ROLES = new Set([ - 'button', - 'link', - 'textbox', - 'searchbox', - 'combobox', - 'checkbox', - 'radio', - 'switch', - 'slider', - 'spinbutton', - 'menuitem', - 'menuitemcheckbox', - 'menuitemradio', - 'tab', - 'option', - 'treeitem' -]) - -const LANDMARK_ROLES = new Set([ - 'banner', - 'navigation', - 'main', - 'complementary', - 'contentinfo', - 'region', - 'form', - 'search' -]) - -const HEADING_PATTERN = /^heading$/ - -const SKIP_ROLES = new Set(['none', 'presentation', 'generic']) - export async function buildSnapshot( sendCommand: CdpCommandSender, iframeSessions?: Map, @@ -187,265 +136,8 @@ export async function buildSnapshot( return { snapshot: lines.join('\n'), refs, refMap } } -function walkTree( - node: AXNode, - nodeById: Map, - depth: number, - entries: SnapshotEntry[], - nextRef: () => number -): void { - if (node.ignored) { - walkChildren(node, nodeById, depth, entries, nextRef) - return - } - - const role = node.role?.value ?? '' - const name = node.name?.value ?? '' - - if (SKIP_ROLES.has(role)) { - walkChildren(node, nodeById, depth, entries, nextRef) - return - } - - const isInteractive = INTERACTIVE_ROLES.has(role) - const isHeading = HEADING_PATTERN.test(role) - const isLandmark = LANDMARK_ROLES.has(role) - const isStaticText = role === 'staticText' || role === 'StaticText' - - if (!isInteractive && !isHeading && !isLandmark && !isStaticText) { - walkChildren(node, nodeById, depth, entries, nextRef) - return - } - - if (!name && !isLandmark) { - walkChildren(node, nodeById, depth, entries, nextRef) - return - } - - const hasFocusable = isInteractive && isFocusable(node) - - if (isLandmark) { - entries.push({ - ref: '', - role: formatLandmarkRole(role, name), - name: name || role, - backendDOMNodeId: node.backendDOMNodeId ?? 0, - depth - }) - walkChildren(node, nodeById, depth + 1, entries, nextRef) - return - } - - if (isHeading) { - entries.push({ - ref: '', - role: 'heading', - name, - backendDOMNodeId: node.backendDOMNodeId ?? 0, - depth - }) - return - } - - if (isStaticText && name.trim().length > 0) { - entries.push({ - ref: '', - role: 'text', - name: name.trim(), - backendDOMNodeId: node.backendDOMNodeId ?? 0, - depth - }) - return - } - - if (isInteractive && (hasFocusable || node.backendDOMNodeId)) { - const ref = `@e${nextRef()}` - entries.push({ - ref, - role: formatInteractiveRole(role), - name: name || '(unlabeled)', - backendDOMNodeId: node.backendDOMNodeId ?? 0, - depth - }) - return - } - - walkChildren(node, nodeById, depth, entries, nextRef) -} - -function walkChildren( - node: AXNode, - nodeById: Map, - depth: number, - entries: SnapshotEntry[], - nextRef: () => number -): void { - if (!node.childIds) { - return - } - for (const childId of node.childIds) { - const child = nodeById.get(childId) - if (child) { - walkTree(child, nodeById, depth, entries, nextRef) - } - } -} - -function isFocusable(node: AXNode): boolean { - if (!node.properties) { - return true - } - const focusable = node.properties.find((p) => p.name === 'focusable') - if (focusable && focusable.value.value === false) { - return false - } - return true -} - -function formatInteractiveRole(role: string): string { - switch (role) { - case 'textbox': - case 'searchbox': - return 'text input' - case 'combobox': - return 'combobox' - case 'menuitem': - case 'menuitemcheckbox': - case 'menuitemradio': - return 'menu item' - case 'spinbutton': - return 'number input' - case 'treeitem': - return 'tree item' - default: - return role - } -} - -function formatLandmarkRole(role: string, name: string): string { - if (name) { - return `[${name}]` - } - switch (role) { - case 'banner': - return '[Header]' - case 'navigation': - return '[Navigation]' - case 'main': - return '[Main Content]' - case 'complementary': - return '[Sidebar]' - case 'contentinfo': - return '[Footer]' - case 'search': - return '[Search]' - default: - return `[${role}]` - } -} - function ordinal(n: number): string { const s = ['th', 'st', 'nd', 'rd'] const v = n % 100 return `${n}${s[(v - 20) % 10] || s[v] || s[0]}` } - -// Why: finds DOM elements that are visually interactive (cursor:pointer, onclick, -// tabindex, contenteditable) but lack standard ARIA roles. These are common in -// modern SPAs where styled
s act as buttons. Returns them as a JS array of -// remote object references that we can resolve to backendNodeIds via CDP. -async function findCursorInteractiveElements( - sendCommand: CdpCommandSender, - existingEntries: SnapshotEntry[] -): Promise { - const existingNodeIds = new Set(existingEntries.map((e) => e.backendDOMNodeId)) - const results: SnapshotEntry[] = [] - - try { - // Single evaluate call that finds interactive elements and returns their info - // along with a way to reference them by index - const { result } = (await sendCommand('Runtime.evaluate', { - expression: `(() => { - const SKIP_ROLES = new Set(['button','link','textbox','checkbox','radio','tab', - 'menuitem','option','switch','slider','combobox','searchbox','spinbutton','treeitem', - 'menuitemcheckbox','menuitemradio']); - const SKIP_TAGS = new Set(['input','button','select','textarea','a']); - const seen = new Set(); - const found = []; - const matchedElements = []; - - function check(el) { - if (seen.has(el)) return; - seen.add(el); - const tag = el.tagName.toLowerCase(); - if (SKIP_TAGS.has(tag)) return; - const role = el.getAttribute('role'); - if (role && SKIP_ROLES.has(role)) return; - const rect = el.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) return; - const text = (el.ariaLabel || el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 80); - if (!text) return; - found.push({ text, tag }); - matchedElements.push(el); - if (found.length >= 50) return; - } - - document.querySelectorAll('[onclick], [tabindex]:not([tabindex="-1"]), [contenteditable="true"]').forEach(el => { - if (found.length < 50) check(el); - }); - document.querySelectorAll('div, span, li, td, img, svg, label').forEach(el => { - if (found.length >= 50) return; - try { - if (window.getComputedStyle(el).cursor === 'pointer') check(el); - } catch {} - }); - - window.__orcaCursorInteractive = matchedElements; - return JSON.stringify(found); - })()`, - returnByValue: true - })) as { result: { value: string } } - - const elements = JSON.parse(result.value) as { text: string; tag: string }[] - - for (let i = 0; i < elements.length; i++) { - try { - const { result: objResult } = (await sendCommand('Runtime.evaluate', { - expression: `window.__orcaCursorInteractive[${i}]` - })) as { result: { objectId?: string } } - - if (!objResult.objectId) { - continue - } - - const { node } = (await sendCommand('DOM.describeNode', { - objectId: objResult.objectId - })) as { node: { backendNodeId: number } } - - if (existingNodeIds.has(node.backendNodeId)) { - continue - } - - results.push({ - ref: '', - role: 'clickable', - name: elements[i].text, - backendDOMNodeId: node.backendNodeId, - depth: 0 - }) - } catch { - continue - } - } - - // Clean up - await sendCommand('Runtime.evaluate', { - expression: 'delete window.__orcaCursorInteractive', - returnByValue: true - }) - } catch { - // DOM query failed — not critical, just return empty - } - - return results -} diff --git a/src/main/ipc/browser-grab-ipc.ts b/src/main/ipc/browser-grab-ipc.ts new file mode 100644 index 00000000000..d13b92a537b --- /dev/null +++ b/src/main/ipc/browser-grab-ipc.ts @@ -0,0 +1,179 @@ +import { ipcMain } from 'electron' +import { browserManager } from '../browser/browser-manager' +import { isTrustedBrowserRenderer } from './browser-renderer-trust' +import { waitForNextTabRegistration } from './browser-tab-registration-wait' +import type { + BrowserSetGrabModeArgs, + BrowserSetGrabModeResult, + BrowserAwaitGrabSelectionArgs, + BrowserGrabResult, + BrowserCancelGrabArgs, + BrowserCaptureSelectionScreenshotArgs, + BrowserCaptureSelectionScreenshotResult, + BrowserExtractHoverArgs, + BrowserExtractHoverResult +} from '../../shared/browser-grab-types' + +const grabModeIntentByPageId = new Map() +const grabModeOperationByPageId = new Map>() +const GRAB_REGISTRATION_WAIT_MS = 1_000 + +function queueGrabModeOperation( + browserPageId: string, + operation: () => Promise +): Promise { + const previous = grabModeOperationByPageId.get(browserPageId) ?? Promise.resolve() + const result = previous.then(operation) + const completion = result.then( + () => {}, + () => {} + ) + grabModeOperationByPageId.set(browserPageId, completion) + return result.finally(() => { + if (grabModeOperationByPageId.get(browserPageId) === completion) { + grabModeOperationByPageId.delete(browserPageId) + } + }) +} + +export function resetGrabModeState(): void { + grabModeIntentByPageId.clear() + // Why: a stale in-flight chain from a prior registration would block new operations forever. + grabModeOperationByPageId.clear() +} + +export function disposeGrabModeStateForPage(browserPageId: string): void { + grabModeIntentByPageId.delete(browserPageId) + // Why: don't let a reused browserPageId queue behind the destroyed guest's pending chain. + grabModeOperationByPageId.delete(browserPageId) +} + +export function registerBrowserGrabHandlers(): void { + ipcMain.removeHandler('browser:setGrabMode') + ipcMain.removeHandler('browser:awaitGrabSelection') + ipcMain.removeHandler('browser:cancelGrab') + ipcMain.removeHandler('browser:captureSelectionScreenshot') + ipcMain.removeHandler('browser:extractHoverPayload') + + ipcMain.handle( + 'browser:setGrabMode', + async (event, args: BrowserSetGrabModeArgs): Promise => { + if (!isTrustedBrowserRenderer(event.sender)) { + return { ok: false, reason: 'not-authorized' } + } + const intent = { + generation: (grabModeIntentByPageId.get(args.browserPageId)?.generation ?? 0) + 1, + enabled: args.enabled + } + grabModeIntentByPageId.set(args.browserPageId, intent) + const isCurrentIntent = (): boolean => + grabModeIntentByPageId.get(args.browserPageId) === intent + let guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) + if (!guest && args.enabled) { + // Why: fast file:// pages can expose the toolbar before did-attach registration reaches main. + await waitForNextTabRegistration(args.browserPageId, GRAB_REGISTRATION_WAIT_MS).catch( + () => {} + ) + if (!isCurrentIntent()) { + return { ok: true } + } + guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) + } + if (!guest) { + if (!args.enabled) { + return { ok: true } + } + return { ok: false, reason: 'not-ready' } + } + return queueGrabModeOperation(args.browserPageId, async () => { + if (!isCurrentIntent()) { + return { ok: true } + } + guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) + if (!guest) { + return args.enabled ? { ok: false, reason: 'not-ready' } : { ok: true } + } + const success = await browserManager.setGrabMode(args.browserPageId, args.enabled, guest) + if (!isCurrentIntent()) { + return { ok: true } + } + return success ? { ok: true } : { ok: false, reason: 'injection-failed' } + }) + } + ) + + ipcMain.handle( + 'browser:awaitGrabSelection', + async (event, args: BrowserAwaitGrabSelectionArgs): Promise => { + if (!isTrustedBrowserRenderer(event.sender)) { + return { opId: args.opId, kind: 'error', reason: 'Not authorized' } + } + const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) + if (!guest) { + return { opId: args.opId, kind: 'error', reason: 'Guest not ready' } + } + // Why: no hasActiveGrabOp guard here — awaitGrabSelection already handles + // the conflict by cancelling the previous op. Blocking at the IPC layer + // would create a race window where rearm() fails if the previous IPC call + // hasn't fully resolved yet. + return browserManager.awaitGrabSelection(args.browserPageId, args.opId, guest) + } + ) + + ipcMain.handle('browser:cancelGrab', (event, args: BrowserCancelGrabArgs): boolean => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + // Why: verify the sender actually owns this tab, consistent with the + // authorization check in setGrabMode/awaitGrabSelection/captureScreenshot. + const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) + if (!guest) { + return false + } + browserManager.cancelGrabOp(args.browserPageId, 'user') + return true + }) + + ipcMain.handle( + 'browser:captureSelectionScreenshot', + async ( + event, + args: BrowserCaptureSelectionScreenshotArgs + ): Promise => { + if (!isTrustedBrowserRenderer(event.sender)) { + return { ok: false, reason: 'Not authorized' } + } + const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) + if (!guest) { + return { ok: false, reason: 'Guest not ready' } + } + const screenshot = await browserManager.captureSelectionScreenshot( + args.browserPageId, + args.rect, + guest + ) + if (!screenshot) { + return { ok: false, reason: 'Screenshot capture failed' } + } + return { ok: true, screenshot } + } + ) + + ipcMain.handle( + 'browser:extractHoverPayload', + async (event, args: BrowserExtractHoverArgs): Promise => { + if (!isTrustedBrowserRenderer(event.sender)) { + return { ok: false, reason: 'Not authorized' } + } + const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) + if (!guest) { + return { ok: false, reason: 'Guest not ready' } + } + const payload = await browserManager.extractHoverPayload(args.browserPageId, guest) + if (!payload) { + return { ok: false, reason: 'No element hovered' } + } + return { ok: true, payload } + } + ) +} diff --git a/src/main/ipc/browser-guest-view-ipc.ts b/src/main/ipc/browser-guest-view-ipc.ts new file mode 100644 index 00000000000..a10c5c14c8e --- /dev/null +++ b/src/main/ipc/browser-guest-view-ipc.ts @@ -0,0 +1,99 @@ +import { ipcMain } from 'electron' +import { browserManager } from '../browser/browser-manager' +import { isTrustedBrowserRenderer } from './browser-renderer-trust' +import type { BrowserViewportOverride } from '../../shared/browser-workspace-types' +import { + isValidBrowserAnnotationViewportBridgeMarkers, + isValidBrowserAnnotationViewportBridgeToken, + type BrowserSetAnnotationViewportBridgeArgs +} from '../../shared/browser-annotation-viewport-bridge' + +export function registerBrowserGuestViewHandlers(): void { + ipcMain.removeHandler('browser:openDevTools') + ipcMain.removeHandler('browser:setViewportOverride') + ipcMain.removeHandler('browser:setAnnotationViewportBridge') + ipcMain.removeHandler('browser:acceptDownload') + ipcMain.removeHandler('browser:cancelDownload') + + ipcMain.handle('browser:openDevTools', (event, args: { browserPageId: string }) => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + return browserManager.openDevTools(args.browserPageId) + }) + + ipcMain.handle( + 'browser:setViewportOverride', + ( + event, + args: { + browserPageId: string + override: BrowserViewportOverride | null + } + ) => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + // Why: CDP misbehaves on non-finite/negative metrics (NaN/Infinity can + // wedge Emulation.setDeviceMetricsOverride and leave the page in a + // broken state). Validate at the main-process trust boundary so a buggy + // or compromised renderer cannot corrupt CDP state. + if (args.override !== null) { + const { width, height, deviceScaleFactor, mobile } = args.override + const isFinitePositive = (n: unknown): n is number => + typeof n === 'number' && Number.isFinite(n) && n > 0 + if (!isFinitePositive(width) || width < 1 || width > 10000) { + return false + } + if (!isFinitePositive(height) || height < 1 || height > 10000) { + return false + } + if ( + !isFinitePositive(deviceScaleFactor) || + deviceScaleFactor < 0.1 || + deviceScaleFactor > 5 + ) { + return false + } + if (typeof mobile !== 'boolean') { + return false + } + } + return browserManager.setViewportOverride(args.browserPageId, args.override) + } + ) + + ipcMain.handle( + 'browser:setAnnotationViewportBridge', + (event, args: BrowserSetAnnotationViewportBridgeArgs): Promise | boolean => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + if ( + typeof args?.browserPageId !== 'string' || + typeof args.enabled !== 'boolean' || + typeof args.emitViewport !== 'boolean' || + !isValidBrowserAnnotationViewportBridgeMarkers(args.markers) || + !isValidBrowserAnnotationViewportBridgeToken(args.token) + ) { + return false + } + return browserManager.setAnnotationViewportBridge(args.browserPageId, { + enabled: args.enabled, + emitViewport: args.emitViewport, + markers: args.markers, + token: args.token + }) + } + ) + + ipcMain.handle('browser:cancelDownload', (event, args: { downloadId: string }) => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + return browserManager.cancelDownload({ + downloadId: args.downloadId, + senderWebContentsId: event.sender.id + }) + }) +} diff --git a/src/main/ipc/browser-renderer-trust.ts b/src/main/ipc/browser-renderer-trust.ts new file mode 100644 index 00000000000..029d8a58303 --- /dev/null +++ b/src/main/ipc/browser-renderer-trust.ts @@ -0,0 +1,25 @@ +let trustedBrowserRendererWebContentsId: number | null = null + +export function setTrustedBrowserRendererWebContentsId(webContentsId: number | null): void { + trustedBrowserRendererWebContentsId = webContentsId +} + +export function isTrustedBrowserRenderer(sender: Electron.WebContents): boolean { + if (sender.isDestroyed() || sender.getType() !== 'window') { + return false + } + if (trustedBrowserRendererWebContentsId != null) { + return sender.id === trustedBrowserRendererWebContentsId + } + + const senderUrl = sender.getURL() + if (process.env.ELECTRON_RENDERER_URL) { + try { + return new URL(senderUrl).origin === new URL(process.env.ELECTRON_RENDERER_URL).origin + } catch { + return false + } + } + + return senderUrl.startsWith('file://') +} diff --git a/src/main/ipc/browser-session-profile-ipc.test.ts b/src/main/ipc/browser-session-profile-ipc.test.ts index 046255d6267..448fe67cb23 100644 --- a/src/main/ipc/browser-session-profile-ipc.test.ts +++ b/src/main/ipc/browser-session-profile-ipc.test.ts @@ -33,7 +33,8 @@ vi.mock('../browser/browser-cookie-import', () => ({ selectBrowserProfile: vi.fn() })) -import { registerBrowserHandlers, setTrustedBrowserRendererWebContentsId } from './browser' +import { registerBrowserHandlers } from './browser' +import { setTrustedBrowserRendererWebContentsId } from './browser-renderer-trust' describe('browser session profile IPC', () => { beforeEach(() => { diff --git a/src/main/ipc/browser-session-profile-ipc.ts b/src/main/ipc/browser-session-profile-ipc.ts new file mode 100644 index 00000000000..3d1cbad1766 --- /dev/null +++ b/src/main/ipc/browser-session-profile-ipc.ts @@ -0,0 +1,194 @@ +import { BrowserWindow, ipcMain } from 'electron' +import { browserSessionRegistry } from '../browser/browser-session-registry' +import { isTrustedBrowserRenderer } from './browser-renderer-trust' +import { + pickCookieFile, + importCookiesFromFile, + detectInstalledBrowsers, + selectBrowserProfile, + importCookiesFromBrowser +} from '../browser/browser-cookie-import' +import type { + BrowserCookieImportResult, + BrowserSessionProfile, + BrowserSessionProfileCreateOptions, + BrowserSessionProfileScope +} from '../../shared/browser-workspace-types' + +export function registerBrowserSessionProfileHandlers(): void { + ipcMain.removeHandler('browser:session:listProfiles') + ipcMain.removeHandler('browser:session:createProfile') + ipcMain.removeHandler('browser:session:deleteProfile') + ipcMain.removeHandler('browser:session:importCookies') + ipcMain.removeHandler('browser:session:resolvePartition') + + ipcMain.handle('browser:session:listProfiles', (event): BrowserSessionProfile[] => { + if (!isTrustedBrowserRenderer(event.sender)) { + return [] + } + return browserSessionRegistry.listProfiles() + }) + + ipcMain.handle( + 'browser:session:createProfile', + ( + event, + args: { + scope: BrowserSessionProfileScope + label: string + } & BrowserSessionProfileCreateOptions + ): BrowserSessionProfile | null => { + if (!isTrustedBrowserRenderer(event.sender)) { + return null + } + return browserSessionRegistry.createProfile(args.scope, args.label, { + userAgentMode: args.userAgentMode + }) + } + ) + + ipcMain.handle( + 'browser:session:deleteProfile', + async (event, args: { profileId: string }): Promise => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + return browserSessionRegistry.deleteProfile(args.profileId) + } + ) + + ipcMain.handle( + 'browser:session:importCookies', + async (event, args: { profileId: string }): Promise => { + if (!isTrustedBrowserRenderer(event.sender)) { + return { ok: false, reason: 'Not authorized' } + } + const profile = browserSessionRegistry.getProfile(args.profileId) + if (!profile) { + return { ok: false, reason: 'Session profile not found.' } + } + + const parent = BrowserWindow.fromWebContents(event.sender) + const filePath = await pickCookieFile(parent) + if (!filePath) { + return { ok: false, reason: 'canceled' } + } + + const result = await importCookiesFromFile(filePath, profile.partition) + if (result.ok) { + browserSessionRegistry.updateProfileSource(args.profileId, { + browserFamily: 'manual', + importedAt: Date.now() + }) + return { ...result, profileId: args.profileId } + } + return result + } + ) + + ipcMain.handle( + 'browser:session:resolvePartition', + (event, args: { profileId: string | null }): string | null => { + if (!isTrustedBrowserRenderer(event.sender)) { + return null + } + return browserSessionRegistry.resolvePartition(args.profileId) + } + ) + + ipcMain.removeHandler('browser:session:clearDefaultCookies') + + ipcMain.handle('browser:session:clearDefaultCookies', async (event): Promise => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + return browserSessionRegistry.clearDefaultSessionCookies() + }) + + ipcMain.removeHandler('browser:session:detectBrowsers') + ipcMain.removeHandler('browser:session:importFromBrowser') + + ipcMain.handle( + 'browser:session:detectBrowsers', + ( + event + ): { + family: string + label: string + profiles: { name: string; directory: string }[] + selectedProfile: string + }[] => { + if (!isTrustedBrowserRenderer(event.sender)) { + return [] + } + // Why: the renderer only needs family/label/profiles for the UI picker. + // Strip cookiesPath, keychainService, and keychainAccount to avoid + // exposing filesystem paths and credential store identifiers to the renderer. + return detectInstalledBrowsers().map((b) => ({ + family: b.family, + label: b.label, + profiles: b.profiles, + selectedProfile: b.selectedProfile + })) + } + ) + + ipcMain.handle( + 'browser:session:importFromBrowser', + async ( + event, + args: { profileId: string; browserFamily: string; browserProfile?: string } + ): Promise => { + if (!isTrustedBrowserRenderer(event.sender)) { + return { ok: false, reason: 'Not authorized' } + } + const profile = browserSessionRegistry.getProfile(args.profileId) + if (!profile) { + return { ok: false, reason: 'Session profile not found.' } + } + + // Why: browserProfile comes from the renderer and is used to construct + // a filesystem path. Reject traversal characters to prevent a compromised + // renderer from reading arbitrary files via the cookie import pipeline. + if ( + args.browserProfile && + (/[/\\]/.test(args.browserProfile) || args.browserProfile.includes('..')) + ) { + return { ok: false, reason: 'Invalid browser profile name.' } + } + + const browsers = detectInstalledBrowsers() + let browser = browsers.find((b) => b.family === args.browserFamily) + if (!browser) { + return { ok: false, reason: 'Browser not found on this system.' } + } + + // Why: if the user selected a non-default profile from the picker, + // resolve the cookies path for that specific profile. + if (args.browserProfile && args.browserProfile !== browser.selectedProfile) { + const reselected = selectBrowserProfile(browser, args.browserProfile) + if (!reselected) { + return { + ok: false, + reason: `No cookies database found for profile "${args.browserProfile}".` + } + } + browser = reselected + } + + const result = await importCookiesFromBrowser(browser, profile.partition) + if (result.ok) { + const profileName = + browser.profiles.find((p) => p.directory === browser.selectedProfile)?.name ?? + browser.selectedProfile + browserSessionRegistry.updateProfileSource(args.profileId, { + browserFamily: browser.family, + profileName, + importedAt: Date.now() + }) + return { ...result, profileId: args.profileId } + } + return result + } + ) +} diff --git a/src/main/ipc/browser-tab-registration-wait.ts b/src/main/ipc/browser-tab-registration-wait.ts new file mode 100644 index 00000000000..6b8af1d8496 --- /dev/null +++ b/src/main/ipc/browser-tab-registration-wait.ts @@ -0,0 +1,122 @@ +import { webContents } from 'electron' +import { browserManager } from '../browser/browser-manager' + +// Why: CLI-driven tab creation must wait until the renderer mounts the webview +// and calls registerGuest, so the tab has a webContentsId and is operable by +// subsequent commands. Multiple commands can wait for the same page during +// startup, so keep all one-shot resolvers keyed by browserPageId. +const pendingTabRegistrations = new Map void>>() +const pendingWorktreeTabRegistrations = new Map void>>() +const pendingAnyTabRegistrations = new Set<() => void>() + +function waitForRegistrationSet( + registrationResolvers: Set<() => void>, + timeoutMs: number, + onEmpty: () => void +): Promise { + return new Promise((resolve, reject) => { + const resolveRegistration = (): void => { + clearTimeout(timer) + resolve() + } + const timer = setTimeout(() => { + registrationResolvers.delete(resolveRegistration) + if (registrationResolvers.size === 0) { + onEmpty() + } + reject(new Error('Tab registration timed out')) + }, timeoutMs) + registrationResolvers.add(resolveRegistration) + }) +} + +function resolvePendingRegistrations(registrationResolvers: Set<() => void> | undefined): void { + if (!registrationResolvers) { + return + } + for (const pendingResolve of registrationResolvers) { + pendingResolve() + } +} + +export function isLiveBrowserWebContentsId(webContentsId: number | null | undefined): boolean { + if (webContentsId == null) { + return false + } + const guest = webContents.fromId(webContentsId) + return Boolean(guest && !guest.isDestroyed()) +} + +function hasRegisteredTabForWorktree(worktreeId: string): boolean { + for (const [browserPageId, webContentsId] of browserManager.getWebContentsIdByTabId()) { + if ( + browserManager.getWorktreeIdForTab(browserPageId) === worktreeId && + isLiveBrowserWebContentsId(webContentsId) + ) { + return true + } + } + return false +} + +export function waitForTabRegistration(browserPageId: string, timeoutMs = 8_000): Promise { + if (isLiveBrowserWebContentsId(browserManager.getGuestWebContentsId(browserPageId))) { + return Promise.resolve() + } + return waitForNextTabRegistration(browserPageId, timeoutMs) +} + +export function waitForNextTabRegistration( + browserPageId: string, + timeoutMs: number +): Promise { + let registrationResolvers = pendingTabRegistrations.get(browserPageId) + if (!registrationResolvers) { + registrationResolvers = new Set() + pendingTabRegistrations.set(browserPageId, registrationResolvers) + } + return waitForRegistrationSet(registrationResolvers, timeoutMs, () => { + pendingTabRegistrations.delete(browserPageId) + }) +} + +export function waitForWorktreeTabRegistration( + worktreeId: string | undefined, + timeoutMs = 8_000 +): Promise { + if (!worktreeId) { + return waitForAnyTabRegistration(timeoutMs) + } + if (hasRegisteredTabForWorktree(worktreeId)) { + return Promise.resolve() + } + let registrationResolvers = pendingWorktreeTabRegistrations.get(worktreeId) + if (!registrationResolvers) { + registrationResolvers = new Set() + pendingWorktreeTabRegistrations.set(worktreeId, registrationResolvers) + } + return waitForRegistrationSet(registrationResolvers, timeoutMs, () => { + pendingWorktreeTabRegistrations.delete(worktreeId) + }) +} + +export function waitForAnyTabRegistration(timeoutMs = 8_000): Promise { + for (const webContentsId of browserManager.getWebContentsIdByTabId().values()) { + if (isLiveBrowserWebContentsId(webContentsId)) { + return Promise.resolve() + } + } + return waitForRegistrationSet(pendingAnyTabRegistrations, timeoutMs, () => {}) +} + +export function resolveTabRegistrationWaiters(browserPageId: string, worktreeId: string): void { + const pendingResolves = pendingTabRegistrations.get(browserPageId) + pendingTabRegistrations.delete(browserPageId) + resolvePendingRegistrations(pendingResolves) + const pendingWorktreeResolves = pendingWorktreeTabRegistrations.get(worktreeId) + pendingWorktreeTabRegistrations.delete(worktreeId) + resolvePendingRegistrations(pendingWorktreeResolves) + const pendingAnyResolves = new Set(pendingAnyTabRegistrations) + pendingAnyTabRegistrations.clear() + resolvePendingRegistrations(pendingAnyResolves) +} diff --git a/src/main/ipc/browser.test.ts b/src/main/ipc/browser.test.ts index 4b233c17108..feb0245cc49 100644 --- a/src/main/ipc/browser.test.ts +++ b/src/main/ipc/browser.test.ts @@ -68,13 +68,12 @@ vi.mock('../browser/browser-manager', () => ({ } })) +import { registerBrowserHandlers, setAgentBrowserBridgeRef } from './browser' import { - registerBrowserHandlers, - setAgentBrowserBridgeRef, waitForAnyTabRegistration, waitForTabRegistration, waitForWorktreeTabRegistration -} from './browser' +} from './browser-tab-registration-wait' describe('registerBrowserHandlers', () => { beforeEach(() => { diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index 88c6e140afa..4a8e0fad03a 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -1,93 +1,23 @@ -/* eslint-disable max-lines -- Why: browser IPC handlers must be registered together so the - trust boundary (isTrustedBrowserRenderer) and handler teardown stay consistent. */ -import { BrowserWindow, ipcMain, webContents } from 'electron' +import { ipcMain, webContents } from 'electron' import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import { browserSessionRegistry } from '../browser/browser-session-registry' +import { isTrustedBrowserRenderer } from './browser-renderer-trust' import { - pickCookieFile, - importCookiesFromFile, - detectInstalledBrowsers, - selectBrowserProfile, - importCookiesFromBrowser -} from '../browser/browser-cookie-import' -import type { - BrowserSetGrabModeArgs, - BrowserSetGrabModeResult, - BrowserAwaitGrabSelectionArgs, - BrowserGrabResult, - BrowserCancelGrabArgs, - BrowserCaptureSelectionScreenshotArgs, - BrowserCaptureSelectionScreenshotResult, - BrowserExtractHoverArgs, - BrowserExtractHoverResult -} from '../../shared/browser-grab-types' -import type { - BrowserCertificateProceedResult, - BrowserCookieImportResult, - BrowserSessionProfile, - BrowserSessionProfileCreateOptions, - BrowserSessionProfileScope, - BrowserViewportOverride -} from '../../shared/browser-workspace-types' + isLiveBrowserWebContentsId, + resolveTabRegistrationWaiters +} from './browser-tab-registration-wait' +import { registerBrowserGuestViewHandlers } from './browser-guest-view-ipc' import { - isValidBrowserAnnotationViewportBridgeMarkers, - isValidBrowserAnnotationViewportBridgeToken, - type BrowserSetAnnotationViewportBridgeArgs -} from '../../shared/browser-annotation-viewport-bridge' + disposeGrabModeStateForPage, + registerBrowserGrabHandlers, + resetGrabModeState +} from './browser-grab-ipc' +import { registerBrowserSessionProfileHandlers } from './browser-session-profile-ipc' +import type { BrowserCertificateProceedResult } from '../../shared/browser-workspace-types' -let trustedBrowserRendererWebContentsId: number | null = null let agentBrowserBridgeRef: AgentBrowserBridge | null = null -// Why: CLI-driven tab creation must wait until the renderer mounts the webview -// and calls registerGuest, so the tab has a webContentsId and is operable by -// subsequent commands. Multiple commands can wait for the same page during -// startup, so keep all one-shot resolvers keyed by browserPageId. -const pendingTabRegistrations = new Map void>>() -const pendingWorktreeTabRegistrations = new Map void>>() -const pendingAnyTabRegistrations = new Set<() => void>() -const grabModeIntentByPageId = new Map() -const grabModeOperationByPageId = new Map>() -const GRAB_REGISTRATION_WAIT_MS = 1_000 - -function waitForRegistrationSet( - registrationResolvers: Set<() => void>, - timeoutMs: number, - onEmpty: () => void -): Promise { - return new Promise((resolve, reject) => { - const resolveRegistration = (): void => { - clearTimeout(timer) - resolve() - } - const timer = setTimeout(() => { - registrationResolvers.delete(resolveRegistration) - if (registrationResolvers.size === 0) { - onEmpty() - } - reject(new Error('Tab registration timed out')) - }, timeoutMs) - registrationResolvers.add(resolveRegistration) - }) -} - -function resolvePendingRegistrations(registrationResolvers: Set<() => void> | undefined): void { - if (!registrationResolvers) { - return - } - for (const pendingResolve of registrationResolvers) { - pendingResolve() - } -} - -function isLiveBrowserWebContentsId(webContentsId: number | null | undefined): boolean { - if (webContentsId == null) { - return false - } - const guest = webContents.fromId(webContentsId) - return Boolean(guest && !guest.isDestroyed()) -} - type BrowserGuestRegistrationArgs = { browserPageId: string workspaceId: string @@ -96,129 +26,16 @@ type BrowserGuestRegistrationArgs = { webContentsId: number } -function hasRegisteredTabForWorktree(worktreeId: string): boolean { - for (const [browserPageId, webContentsId] of browserManager.getWebContentsIdByTabId()) { - if ( - browserManager.getWorktreeIdForTab(browserPageId) === worktreeId && - isLiveBrowserWebContentsId(webContentsId) - ) { - return true - } - } - return false -} - -export function waitForTabRegistration(browserPageId: string, timeoutMs = 8_000): Promise { - if (isLiveBrowserWebContentsId(browserManager.getGuestWebContentsId(browserPageId))) { - return Promise.resolve() - } - return waitForNextTabRegistration(browserPageId, timeoutMs) -} - -function waitForNextTabRegistration(browserPageId: string, timeoutMs: number): Promise { - let registrationResolvers = pendingTabRegistrations.get(browserPageId) - if (!registrationResolvers) { - registrationResolvers = new Set() - pendingTabRegistrations.set(browserPageId, registrationResolvers) - } - return waitForRegistrationSet(registrationResolvers, timeoutMs, () => { - pendingTabRegistrations.delete(browserPageId) - }) -} - -function queueGrabModeOperation( - browserPageId: string, - operation: () => Promise -): Promise { - const previous = grabModeOperationByPageId.get(browserPageId) ?? Promise.resolve() - const result = previous.then(operation) - const completion = result.then( - () => {}, - () => {} - ) - grabModeOperationByPageId.set(browserPageId, completion) - return result.finally(() => { - if (grabModeOperationByPageId.get(browserPageId) === completion) { - grabModeOperationByPageId.delete(browserPageId) - } - }) -} - -export function waitForWorktreeTabRegistration( - worktreeId: string | undefined, - timeoutMs = 8_000 -): Promise { - if (!worktreeId) { - return waitForAnyTabRegistration(timeoutMs) - } - if (hasRegisteredTabForWorktree(worktreeId)) { - return Promise.resolve() - } - let registrationResolvers = pendingWorktreeTabRegistrations.get(worktreeId) - if (!registrationResolvers) { - registrationResolvers = new Set() - pendingWorktreeTabRegistrations.set(worktreeId, registrationResolvers) - } - return waitForRegistrationSet(registrationResolvers, timeoutMs, () => { - pendingWorktreeTabRegistrations.delete(worktreeId) - }) -} - -export function waitForAnyTabRegistration(timeoutMs = 8_000): Promise { - for (const webContentsId of browserManager.getWebContentsIdByTabId().values()) { - if (isLiveBrowserWebContentsId(webContentsId)) { - return Promise.resolve() - } - } - return waitForRegistrationSet(pendingAnyTabRegistrations, timeoutMs, () => {}) -} - -export function setTrustedBrowserRendererWebContentsId(webContentsId: number | null): void { - trustedBrowserRendererWebContentsId = webContentsId -} - export function setAgentBrowserBridgeRef(bridge: AgentBrowserBridge | null): void { agentBrowserBridgeRef = bridge } -function isTrustedBrowserRenderer(sender: Electron.WebContents): boolean { - if (sender.isDestroyed() || sender.getType() !== 'window') { - return false - } - if (trustedBrowserRendererWebContentsId != null) { - return sender.id === trustedBrowserRendererWebContentsId - } - - const senderUrl = sender.getURL() - if (process.env.ELECTRON_RENDERER_URL) { - try { - return new URL(senderUrl).origin === new URL(process.env.ELECTRON_RENDERER_URL).origin - } catch { - return false - } - } - - return senderUrl.startsWith('file://') -} - export function registerBrowserHandlers(): void { - grabModeIntentByPageId.clear() - // Why: a stale in-flight chain from a prior registration would block new operations forever. - grabModeOperationByPageId.clear() + resetGrabModeState() ipcMain.removeHandler('browser:registerGuest') ipcMain.removeHandler('browser:isGuestRegistered') ipcMain.removeHandler('browser:repairGuestRegistration') ipcMain.removeHandler('browser:unregisterGuest') - ipcMain.removeHandler('browser:openDevTools') - ipcMain.removeHandler('browser:setViewportOverride') - ipcMain.removeHandler('browser:setAnnotationViewportBridge') - ipcMain.removeHandler('browser:acceptDownload') - ipcMain.removeHandler('browser:cancelDownload') - ipcMain.removeHandler('browser:setGrabMode') - ipcMain.removeHandler('browser:awaitGrabSelection') - ipcMain.removeHandler('browser:cancelGrab') - ipcMain.removeHandler('browser:captureSelectionScreenshot') - ipcMain.removeHandler('browser:extractHoverPayload') ipcMain.removeHandler('browser:activeTabChanged') ipcMain.removeHandler('browser:proceedCertificate') @@ -268,15 +85,7 @@ export function registerBrowserHandlers(): void { if (agentBrowserBridgeRef && previousWcId !== null && previousWcId !== args.webContentsId) { agentBrowserBridgeRef.onProcessSwap(args.browserPageId, args.webContentsId, previousWcId) } - const pendingResolves = pendingTabRegistrations.get(args.browserPageId) - pendingTabRegistrations.delete(args.browserPageId) - resolvePendingRegistrations(pendingResolves) - const pendingWorktreeResolves = pendingWorktreeTabRegistrations.get(args.worktreeId) - pendingWorktreeTabRegistrations.delete(args.worktreeId) - resolvePendingRegistrations(pendingWorktreeResolves) - const pendingAnyResolves = new Set(pendingAnyTabRegistrations) - pendingAnyTabRegistrations.clear() - resolvePendingRegistrations(pendingAnyResolves) + resolveTabRegistrationWaiters(args.browserPageId, args.worktreeId) return true } @@ -316,9 +125,7 @@ export function registerBrowserHandlers(): void { agentBrowserBridgeRef.onTabClosed(wcId) } browserManager.unregisterGuest(args.browserPageId) - grabModeIntentByPageId.delete(args.browserPageId) - // Why: don't let a reused browserPageId queue behind the destroyed guest's pending chain. - grabModeOperationByPageId.delete(args.browserPageId) + disposeGrabModeStateForPage(args.browserPageId) return true }) @@ -362,387 +169,13 @@ export function registerBrowserHandlers(): void { return true }) - ipcMain.handle('browser:openDevTools', (event, args: { browserPageId: string }) => { - if (!isTrustedBrowserRenderer(event.sender)) { - return false - } - return browserManager.openDevTools(args.browserPageId) - }) - - ipcMain.handle( - 'browser:setViewportOverride', - ( - event, - args: { - browserPageId: string - override: BrowserViewportOverride | null - } - ) => { - if (!isTrustedBrowserRenderer(event.sender)) { - return false - } - // Why: CDP misbehaves on non-finite/negative metrics (NaN/Infinity can - // wedge Emulation.setDeviceMetricsOverride and leave the page in a - // broken state). Validate at the main-process trust boundary so a buggy - // or compromised renderer cannot corrupt CDP state. - if (args.override !== null) { - const { width, height, deviceScaleFactor, mobile } = args.override - const isFinitePositive = (n: unknown): n is number => - typeof n === 'number' && Number.isFinite(n) && n > 0 - if (!isFinitePositive(width) || width < 1 || width > 10000) { - return false - } - if (!isFinitePositive(height) || height < 1 || height > 10000) { - return false - } - if ( - !isFinitePositive(deviceScaleFactor) || - deviceScaleFactor < 0.1 || - deviceScaleFactor > 5 - ) { - return false - } - if (typeof mobile !== 'boolean') { - return false - } - } - return browserManager.setViewportOverride(args.browserPageId, args.override) - } - ) - - ipcMain.handle( - 'browser:setAnnotationViewportBridge', - (event, args: BrowserSetAnnotationViewportBridgeArgs): Promise | boolean => { - if (!isTrustedBrowserRenderer(event.sender)) { - return false - } - if ( - typeof args?.browserPageId !== 'string' || - typeof args.enabled !== 'boolean' || - typeof args.emitViewport !== 'boolean' || - !isValidBrowserAnnotationViewportBridgeMarkers(args.markers) || - !isValidBrowserAnnotationViewportBridgeToken(args.token) - ) { - return false - } - return browserManager.setAnnotationViewportBridge(args.browserPageId, { - enabled: args.enabled, - emitViewport: args.emitViewport, - markers: args.markers, - token: args.token - }) - } - ) - - ipcMain.handle('browser:cancelDownload', (event, args: { downloadId: string }) => { - if (!isTrustedBrowserRenderer(event.sender)) { - return false - } - return browserManager.cancelDownload({ - downloadId: args.downloadId, - senderWebContentsId: event.sender.id - }) - }) + registerBrowserGuestViewHandlers() // --- Browser Context Grab IPC --- - ipcMain.handle( - 'browser:setGrabMode', - async (event, args: BrowserSetGrabModeArgs): Promise => { - if (!isTrustedBrowserRenderer(event.sender)) { - return { ok: false, reason: 'not-authorized' } - } - const intent = { - generation: (grabModeIntentByPageId.get(args.browserPageId)?.generation ?? 0) + 1, - enabled: args.enabled - } - grabModeIntentByPageId.set(args.browserPageId, intent) - const isCurrentIntent = (): boolean => - grabModeIntentByPageId.get(args.browserPageId) === intent - let guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) - if (!guest && args.enabled) { - // Why: fast file:// pages can expose the toolbar before did-attach registration reaches main. - await waitForNextTabRegistration(args.browserPageId, GRAB_REGISTRATION_WAIT_MS).catch( - () => {} - ) - if (!isCurrentIntent()) { - return { ok: true } - } - guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) - } - if (!guest) { - if (!args.enabled) { - return { ok: true } - } - return { ok: false, reason: 'not-ready' } - } - return queueGrabModeOperation(args.browserPageId, async () => { - if (!isCurrentIntent()) { - return { ok: true } - } - guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) - if (!guest) { - return args.enabled ? { ok: false, reason: 'not-ready' } : { ok: true } - } - const success = await browserManager.setGrabMode(args.browserPageId, args.enabled, guest) - if (!isCurrentIntent()) { - return { ok: true } - } - return success ? { ok: true } : { ok: false, reason: 'injection-failed' } - }) - } - ) - - ipcMain.handle( - 'browser:awaitGrabSelection', - async (event, args: BrowserAwaitGrabSelectionArgs): Promise => { - if (!isTrustedBrowserRenderer(event.sender)) { - return { opId: args.opId, kind: 'error', reason: 'Not authorized' } - } - const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) - if (!guest) { - return { opId: args.opId, kind: 'error', reason: 'Guest not ready' } - } - // Why: no hasActiveGrabOp guard here — awaitGrabSelection already handles - // the conflict by cancelling the previous op. Blocking at the IPC layer - // would create a race window where rearm() fails if the previous IPC call - // hasn't fully resolved yet. - return browserManager.awaitGrabSelection(args.browserPageId, args.opId, guest) - } - ) - - ipcMain.handle('browser:cancelGrab', (event, args: BrowserCancelGrabArgs): boolean => { - if (!isTrustedBrowserRenderer(event.sender)) { - return false - } - // Why: verify the sender actually owns this tab, consistent with the - // authorization check in setGrabMode/awaitGrabSelection/captureScreenshot. - const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) - if (!guest) { - return false - } - browserManager.cancelGrabOp(args.browserPageId, 'user') - return true - }) - - ipcMain.handle( - 'browser:captureSelectionScreenshot', - async ( - event, - args: BrowserCaptureSelectionScreenshotArgs - ): Promise => { - if (!isTrustedBrowserRenderer(event.sender)) { - return { ok: false, reason: 'Not authorized' } - } - const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) - if (!guest) { - return { ok: false, reason: 'Guest not ready' } - } - const screenshot = await browserManager.captureSelectionScreenshot( - args.browserPageId, - args.rect, - guest - ) - if (!screenshot) { - return { ok: false, reason: 'Screenshot capture failed' } - } - return { ok: true, screenshot } - } - ) - - ipcMain.handle( - 'browser:extractHoverPayload', - async (event, args: BrowserExtractHoverArgs): Promise => { - if (!isTrustedBrowserRenderer(event.sender)) { - return { ok: false, reason: 'Not authorized' } - } - const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) - if (!guest) { - return { ok: false, reason: 'Guest not ready' } - } - const payload = await browserManager.extractHoverPayload(args.browserPageId, guest) - if (!payload) { - return { ok: false, reason: 'No element hovered' } - } - return { ok: true, payload } - } - ) + registerBrowserGrabHandlers() // --- Browser Session Profile IPC --- - ipcMain.removeHandler('browser:session:listProfiles') - ipcMain.removeHandler('browser:session:createProfile') - ipcMain.removeHandler('browser:session:deleteProfile') - ipcMain.removeHandler('browser:session:importCookies') - ipcMain.removeHandler('browser:session:resolvePartition') - - ipcMain.handle('browser:session:listProfiles', (event): BrowserSessionProfile[] => { - if (!isTrustedBrowserRenderer(event.sender)) { - return [] - } - return browserSessionRegistry.listProfiles() - }) - - ipcMain.handle( - 'browser:session:createProfile', - ( - event, - args: { - scope: BrowserSessionProfileScope - label: string - } & BrowserSessionProfileCreateOptions - ): BrowserSessionProfile | null => { - if (!isTrustedBrowserRenderer(event.sender)) { - return null - } - return browserSessionRegistry.createProfile(args.scope, args.label, { - userAgentMode: args.userAgentMode - }) - } - ) - - ipcMain.handle( - 'browser:session:deleteProfile', - async (event, args: { profileId: string }): Promise => { - if (!isTrustedBrowserRenderer(event.sender)) { - return false - } - return browserSessionRegistry.deleteProfile(args.profileId) - } - ) - - ipcMain.handle( - 'browser:session:importCookies', - async (event, args: { profileId: string }): Promise => { - if (!isTrustedBrowserRenderer(event.sender)) { - return { ok: false, reason: 'Not authorized' } - } - const profile = browserSessionRegistry.getProfile(args.profileId) - if (!profile) { - return { ok: false, reason: 'Session profile not found.' } - } - - const parent = BrowserWindow.fromWebContents(event.sender) - const filePath = await pickCookieFile(parent) - if (!filePath) { - return { ok: false, reason: 'canceled' } - } - - const result = await importCookiesFromFile(filePath, profile.partition) - if (result.ok) { - browserSessionRegistry.updateProfileSource(args.profileId, { - browserFamily: 'manual', - importedAt: Date.now() - }) - return { ...result, profileId: args.profileId } - } - return result - } - ) - - ipcMain.handle( - 'browser:session:resolvePartition', - (event, args: { profileId: string | null }): string | null => { - if (!isTrustedBrowserRenderer(event.sender)) { - return null - } - return browserSessionRegistry.resolvePartition(args.profileId) - } - ) - - ipcMain.removeHandler('browser:session:clearDefaultCookies') - - ipcMain.handle('browser:session:clearDefaultCookies', async (event): Promise => { - if (!isTrustedBrowserRenderer(event.sender)) { - return false - } - return browserSessionRegistry.clearDefaultSessionCookies() - }) - - ipcMain.removeHandler('browser:session:detectBrowsers') - ipcMain.removeHandler('browser:session:importFromBrowser') - - ipcMain.handle( - 'browser:session:detectBrowsers', - ( - event - ): { - family: string - label: string - profiles: { name: string; directory: string }[] - selectedProfile: string - }[] => { - if (!isTrustedBrowserRenderer(event.sender)) { - return [] - } - // Why: the renderer only needs family/label/profiles for the UI picker. - // Strip cookiesPath, keychainService, and keychainAccount to avoid - // exposing filesystem paths and credential store identifiers to the renderer. - return detectInstalledBrowsers().map((b) => ({ - family: b.family, - label: b.label, - profiles: b.profiles, - selectedProfile: b.selectedProfile - })) - } - ) - - ipcMain.handle( - 'browser:session:importFromBrowser', - async ( - event, - args: { profileId: string; browserFamily: string; browserProfile?: string } - ): Promise => { - if (!isTrustedBrowserRenderer(event.sender)) { - return { ok: false, reason: 'Not authorized' } - } - const profile = browserSessionRegistry.getProfile(args.profileId) - if (!profile) { - return { ok: false, reason: 'Session profile not found.' } - } - - // Why: browserProfile comes from the renderer and is used to construct - // a filesystem path. Reject traversal characters to prevent a compromised - // renderer from reading arbitrary files via the cookie import pipeline. - if ( - args.browserProfile && - (/[/\\]/.test(args.browserProfile) || args.browserProfile.includes('..')) - ) { - return { ok: false, reason: 'Invalid browser profile name.' } - } - - const browsers = detectInstalledBrowsers() - let browser = browsers.find((b) => b.family === args.browserFamily) - if (!browser) { - return { ok: false, reason: 'Browser not found on this system.' } - } - - // Why: if the user selected a non-default profile from the picker, - // resolve the cookies path for that specific profile. - if (args.browserProfile && args.browserProfile !== browser.selectedProfile) { - const reselected = selectBrowserProfile(browser, args.browserProfile) - if (!reselected) { - return { - ok: false, - reason: `No cookies database found for profile "${args.browserProfile}".` - } - } - browser = reselected - } - - const result = await importCookiesFromBrowser(browser, profile.partition) - if (result.ok) { - const profileName = - browser.profiles.find((p) => p.directory === browser.selectedProfile)?.name ?? - browser.selectedProfile - browserSessionRegistry.updateProfileSource(args.profileId, { - browserFamily: browser.family, - profileName, - importedAt: Date.now() - }) - return { ...result, profileId: args.profileId } - } - return result - } - ) + registerBrowserSessionProfileHandlers() } diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 9bee285bff4..b8dd38360fa 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -342,10 +342,13 @@ vi.mock('../window/clipboard-ipc-handlers', () => ({ vi.mock('./browser', () => ({ registerBrowserHandlers: registerBrowserHandlersMock, - setTrustedBrowserRendererWebContentsId: setTrustedBrowserRendererWebContentsIdMock, setAgentBrowserBridgeRef: setAgentBrowserBridgeRefMock })) +vi.mock('./browser-renderer-trust', () => ({ + setTrustedBrowserRendererWebContentsId: setTrustedBrowserRendererWebContentsIdMock +})) + vi.mock('./app', () => ({ registerAppHandlers: registerAppHandlersMock })) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 7825e73c57e..3aec3b48d1b 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -33,11 +33,8 @@ import { registerDashboardPopoutHandlers } from './dashboard-popout' import { registerTerminalPreviewHandlers } from './terminal-preview' import { registerDeveloperPermissionHandlers } from './developer-permissions' import { registerComputerUsePermissionHandlers } from './computer-use-permissions' -import { - setTrustedBrowserRendererWebContentsId, - setAgentBrowserBridgeRef, - registerBrowserHandlers -} from './browser' +import { setAgentBrowserBridgeRef, registerBrowserHandlers } from './browser' +import { setTrustedBrowserRendererWebContentsId } from './browser-renderer-trust' import { registerSessionHandlers } from './session' import { registerSettingsHandlers } from './settings' import { registerDiagnosticsHandlers } from './diagnostics' diff --git a/src/main/runtime/orca-runtime-browser.test.ts b/src/main/runtime/orca-runtime-browser.test.ts index 007a091745d..07cc6058df4 100644 --- a/src/main/runtime/orca-runtime-browser.test.ts +++ b/src/main/runtime/orca-runtime-browser.test.ts @@ -57,7 +57,7 @@ vi.mock('../browser/browser-screencast-stream', () => ({ startBrowserScreencast: startBrowserScreencastMock })) -vi.mock('../ipc/browser', () => ({ +vi.mock('../ipc/browser-tab-registration-wait', () => ({ waitForTabRegistration: waitForTabRegistrationMock, waitForWorktreeTabRegistration: waitForWorktreeTabRegistrationMock })) diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index e076eeadad0..dac2ef92efd 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -57,17 +57,18 @@ import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import type { BrowserBackend } from '../browser/browser-backend' import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' import { BrowserError } from '../browser/cdp-bridge' -import { - startBrowserScreencast, - type BrowserScreencastSession -} from '../browser/browser-screencast-stream' +import { startBrowserScreencast } from '../browser/browser-screencast-stream' +import type { BrowserScreencastSession } from '../browser/browser-screencast-stream-types' import { browserSessionRegistry } from '../browser/browser-session-registry' import { detectInstalledBrowsers, importCookiesFromBrowser, selectBrowserProfile } from '../browser/browser-cookie-import' -import { waitForTabRegistration, waitForWorktreeTabRegistration } from '../ipc/browser' +import { + waitForTabRegistration, + waitForWorktreeTabRegistration +} from '../ipc/browser-tab-registration-wait' import { sendRemoteBrowserScreencastFrame } from './remote-browser-screencast-frame-admission' export type BrowserCommandTargetParams = {