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.
This commit is contained in:
Neil
2026-08-15 17:59:29 -07:00
committed by GitHub
parent 93ab6e142e
commit c8fe5fc8c1
48 changed files with 3528 additions and 2937 deletions
-6
View File
@@ -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
@@ -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.
}
}
}
@@ -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.
}
}
}
@@ -0,0 +1,18 @@
import { webContents } from 'electron'
export type ResolveRenderer = (browserTabId: string) => Electron.WebContents | null
export function resolveRendererWebContents(
rendererWebContentsIdByTabId: ReadonlyMap<string, number>,
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
}
@@ -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
}
@@ -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'
@@ -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.
}
}
}
-600
View File
@@ -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<BrowserPageZoomDirection, 'reset'>
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<string>): 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<string, number>,
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
}
@@ -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<BrowserPageZoomDirection, 'reset'>
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<string>): 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.
}
}
}
+5 -7
View File
@@ -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 {
@@ -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<string, unknown>) : {}
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<string, unknown>) : {}
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<string, unknown>) : {}
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)
}
}
}
@@ -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<string, unknown> = {}
): Promise<unknown> {
let timeout: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
Promise.resolve().then(() => dbg.sendCommand(method, params)),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`Timed out while running ${method}.`))
}, DEBUGGER_COMMAND_TIMEOUT_MS)
})
])
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
}
@@ -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<void>
clear: () => Promise<void>
isOverridden: () => boolean
}
export function createBrowserScreencastDeviceMetrics(
webContents: WebContents,
dbg: Debugger,
options: BrowserScreencastOptions
): BrowserScreencastDeviceMetrics {
let deviceMetricsOverridden = false
const clearDeviceMetricsOverride = async (): Promise<void> => {
if (webContents.isDestroyed() || !dbg.isAttached()) {
deviceMetricsOverridden = false
return
}
await sendDebuggerCommand(dbg, 'Emulation.clearDeviceMetricsOverride')
deviceMetricsOverridden = false
}
const applyDeviceMetricsOverride = async (): Promise<void> => {
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
}
}
@@ -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<string, unknown>) : {}
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
}
@@ -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<typeof setTimeout> | 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
}
}
@@ -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<void>
}
export type BrowserScreencastSnapshotCapture = {
emitSnapshotFrame: (initialOnly: boolean, generation?: number) => Promise<void>
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<typeof setTimeout> | 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<void> => {
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<string, unknown>) : {}
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
}
}
}
@@ -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<ArrayBufferLike>) => boolean | void
onEvent?: (event: BrowserScreencastEvent) => void
onError?: (message: string) => void
}
export type BrowserScreencastSession = { stop: () => void; done: Promise<void> }
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
}
+46 -563
View File
@@ -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<ArrayBufferLike>) => boolean | void
onEvent?: (event: BrowserScreencastEvent) => void
onError?: (message: string) => void
}
export type BrowserScreencastSession = { stop: () => void; done: Promise<void> }
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<string, unknown>) : {}
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<string, unknown> = {}
): Promise<unknown> {
let timeout: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
Promise.resolve().then(() => dbg.sendCommand(method, params)),
new Promise<never>((_, 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<typeof setTimeout> | null = null
let pendingFrame: PendingScreencastFrame | null = null
let pendingFrameTimer: ReturnType<typeof setTimeout> | null = null
let resolveDone!: () => void
const done = new Promise<void>((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<void> => {
if (webContents.isDestroyed() || !dbg.isAttached()) {
deviceMetricsOverridden = false
return
}
await sendDebuggerCommand(dbg, 'Emulation.clearDeviceMetricsOverride')
deviceMetricsOverridden = false
}
const applyDeviceMetricsOverride = async (): Promise<void> => {
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<string, unknown>) : {}
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<string, unknown>) : {}
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<string, unknown>) : {}
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<void> => {
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<string, unknown>) : {}
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 {
@@ -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
}
@@ -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 */
}
}
}
@@ -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<string, string>
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<string, string> =
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<BrowserSessionMeta>
): 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
}
}
@@ -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<string>()
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) */
}
}
}
@@ -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<BrowserSessionProfile>
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)
)
}
+39 -315
View File
@@ -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<string, string>
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<BrowserSessionMeta>): 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<string, string> =
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<BrowserSessionProfile>
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<string>()
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()
@@ -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<WebSocket, Map<number, string>>()
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)
}
}
+126
View File
@@ -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<void> {
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, unknown>,
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<string, unknown> = { 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<string, unknown>,
sessionId?: string
): Promise<unknown> {
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<string, unknown>,
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)
}
}
}
+116
View File
@@ -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<Record<string, unknown> | 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<string, unknown>,
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<string, unknown>,
effectiveSessionId?: string
): Promise<Record<string, unknown> | 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<string, unknown>,
effectiveSessionId?: string
): Promise<void> {
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
)
}
}
@@ -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<string, unknown>): boolean {
return this.pdfStreams.ownsHandle(params)
}
async handlePrintToPdf(
client: WebSocket,
clientId: number,
params: Record<string, unknown>
): Promise<void> {
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<string, unknown>): 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<string, unknown>): void {
this.pdfStreams.close(params)
this.responder.sendResult(clientId, {}, client)
}
handleScreenshot(client: WebSocket, clientId: number, params?: Record<string, unknown>): void {
captureScreenshot(
this.webContents,
params,
(result) => this.responder.sendResult(clientId, result, client),
(message) => this.responder.sendError(clientId, message, client)
)
}
}
@@ -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<string, unknown>,
msgSessionId?: string
): Promise<void> {
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<string, unknown>,
msgSessionId?: string
): Promise<void> {
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, unknown>): string | null {
return Object.keys(params).find((key) => key !== 'ignoreCache') ?? null
}
private async primePageLifecycle(sessionId?: string): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | null = null
const priming = (async (): Promise<void> => {
// 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<void>((resolve) => {
timeout = setTimeout(resolve, LIFECYCLE_PRIMING_TIMEOUT_MS)
timeout.unref?.()
})
])
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
}
}
@@ -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<string>()
private readonly clientBrowserSessionIds = new Set<string>()
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}`
}
}
+117
View File
@@ -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<string, unknown> }
): 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<string, unknown> {
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
}
}
}
+3 -1
View File
@@ -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)
+75 -530
View File
@@ -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<Record<string, unknown> | undefined>
>()
private httpServer: Server | null = null
private wss: WebSocketServer | null = null
private client: WebSocket | null = null
private readonly responseSessionIdsByClient = new WeakMap<WebSocket, Map<number, string>>()
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<string>()
private readonly clientBrowserSessionIds = new Set<string>()
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<string> {
await this.attachDebugger()
await this.debuggerChannel.attachDebugger()
return new Promise<string>((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<void> {
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<string, unknown> {
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<void> {
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, unknown>,
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<string, unknown> = { 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<string, unknown>,
sessionId?: string
): Promise<unknown> {
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<string, unknown>,
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<string, unknown>,
msgSessionId?: string
): Promise<void> {
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<string, unknown>,
msgSessionId?: string
): Promise<void> {
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, unknown>): string | null {
return Object.keys(params).find((key) => key !== 'ignoreCache') ?? null
}
private async primePageLifecycle(sessionId?: string): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | null = null
const priming = (async (): Promise<void> => {
// 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<void>((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<string, unknown>,
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<string, unknown>,
effectiveSessionId?: string
): Promise<Record<string, unknown> | 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<string, unknown>,
effectiveSessionId?: string
): Promise<void> {
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<string, unknown>
): Promise<void> {
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<string, unknown>
): 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<string, unknown>
): void {
this.pdfStreams.close(params)
this.sendResult(clientId, {}, client)
}
private handleScreenshot(
client: WebSocket,
clientId: number,
params?: Record<string, unknown>
): 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
)
}
}
+208
View File
@@ -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<string, AXNode>,
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<string, AXNode>,
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}]`
}
}
@@ -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 <div>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<SnapshotEntry[]> {
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
}
+2 -310
View File
@@ -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<string, unknown>
) => Promise<unknown>
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<string, RefEntry>
}
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<string, string>,
@@ -187,265 +136,8 @@ export async function buildSnapshot(
return { snapshot: lines.join('\n'), refs, refMap }
}
function walkTree(
node: AXNode,
nodeById: Map<string, AXNode>,
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<string, AXNode>,
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 <div>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<SnapshotEntry[]> {
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
}
+179
View File
@@ -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<string, { generation: number; enabled: boolean }>()
const grabModeOperationByPageId = new Map<string, Promise<void>>()
const GRAB_REGISTRATION_WAIT_MS = 1_000
function queueGrabModeOperation(
browserPageId: string,
operation: () => Promise<BrowserSetGrabModeResult>
): Promise<BrowserSetGrabModeResult> {
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<BrowserSetGrabModeResult> => {
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<BrowserGrabResult> => {
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<BrowserCaptureSelectionScreenshotResult> => {
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<BrowserExtractHoverResult> => {
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 }
}
)
}
+99
View File
@@ -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> | 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
})
})
}
+25
View File
@@ -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://')
}
@@ -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(() => {
+194
View File
@@ -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<boolean> => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
return browserSessionRegistry.deleteProfile(args.profileId)
}
)
ipcMain.handle(
'browser:session:importCookies',
async (event, args: { profileId: string }): Promise<BrowserCookieImportResult> => {
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<boolean> => {
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<BrowserCookieImportResult> => {
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
}
)
}
@@ -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<string, Set<() => void>>()
const pendingWorktreeTabRegistrations = new Map<string, Set<() => void>>()
const pendingAnyTabRegistrations = new Set<() => void>()
function waitForRegistrationSet(
registrationResolvers: Set<() => void>,
timeoutMs: number,
onEmpty: () => void
): Promise<void> {
return new Promise<void>((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<void> {
if (isLiveBrowserWebContentsId(browserManager.getGuestWebContentsId(browserPageId))) {
return Promise.resolve()
}
return waitForNextTabRegistration(browserPageId, timeoutMs)
}
export function waitForNextTabRegistration(
browserPageId: string,
timeoutMs: number
): Promise<void> {
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<void> {
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<void> {
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)
}
+2 -3
View File
@@ -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(() => {
+18 -585
View File
@@ -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<string, Set<() => void>>()
const pendingWorktreeTabRegistrations = new Map<string, Set<() => void>>()
const pendingAnyTabRegistrations = new Set<() => void>()
const grabModeIntentByPageId = new Map<string, { generation: number; enabled: boolean }>()
const grabModeOperationByPageId = new Map<string, Promise<void>>()
const GRAB_REGISTRATION_WAIT_MS = 1_000
function waitForRegistrationSet(
registrationResolvers: Set<() => void>,
timeoutMs: number,
onEmpty: () => void
): Promise<void> {
return new Promise<void>((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<void> {
if (isLiveBrowserWebContentsId(browserManager.getGuestWebContentsId(browserPageId))) {
return Promise.resolve()
}
return waitForNextTabRegistration(browserPageId, timeoutMs)
}
function waitForNextTabRegistration(browserPageId: string, timeoutMs: number): Promise<void> {
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<BrowserSetGrabModeResult>
): Promise<BrowserSetGrabModeResult> {
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<void> {
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<void> {
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> | 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<BrowserSetGrabModeResult> => {
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<BrowserGrabResult> => {
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<BrowserCaptureSelectionScreenshotResult> => {
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<BrowserExtractHoverResult> => {
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<boolean> => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
return browserSessionRegistry.deleteProfile(args.profileId)
}
)
ipcMain.handle(
'browser:session:importCookies',
async (event, args: { profileId: string }): Promise<BrowserCookieImportResult> => {
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<boolean> => {
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<BrowserCookieImportResult> => {
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()
}
+4 -1
View File
@@ -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
}))
+2 -5
View File
@@ -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'
@@ -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
}))
+6 -5
View File
@@ -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 = {