mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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
|
||||
}
|
||||
+4
-4
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user