Files
orca/src/main/browser/cdp-debugger-channel.ts
T
Neil c8fe5fc8c1 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.
2026-08-15 17:59:29 -07:00

127 lines
4.5 KiB
TypeScript

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)
}
}
}