import type { Rectangle } from "electron"; import { ipcMain } from "electron"; import { BrowserAutomationExecuteRequestSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas"; import type { BrowserAutomationConsoleLogEntry, BrowserAutomationDialogEvent, } from "@getpaseo/protocol/browser-automation/rpc-schemas"; import type { TabContents, BrowserRegistry, TabImage } from "./service.js"; import type { IsolatedKeyboardInputEvent } from "./trusted-input.js"; import { CdpSessionQueue } from "./cdp-session-queue.js"; import { dialogAcceptValue, handledDialogEvent, MAX_DIALOGS_PER_COMMAND, promptShimDrainScript, promptShimInstallScript, promptShimRestoreScript, } from "./dialog-handling.js"; import { executeAutomationCommand } from "./service.js"; import { BrowserSnapshotEngine } from "./snapshot-engine.js"; import { listRegisteredPaseoBrowserIds, listRegisteredPaseoBrowserIdsForWorkspace, getPaseoBrowserWebContentsForHostWindow, getWorkspaceActivePaseoBrowserIdForHostWindow, getPaseoBrowserWorkspaceId, } from "../browser-webviews/index.js"; const MAX_CONSOLE_MESSAGES_PER_TAB = 200; const consoleMessagesByContentsId = new Map(); const cdpQueuesByContentsId = new Map(); const dialogMonitorsByContentsId = new Map(); const observedContentsIds = new Set(); interface IpcHandlerRegistry { handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void; } interface HostWebContents { readonly id: number; once(event: "destroyed", listener: () => void): void; } export class HostSnapshotEngineRegistry { private readonly entries = new Map< number, { hostContents: HostWebContents; snapshotEngine: BrowserSnapshotEngine } >(); public get(hostContents: HostWebContents): BrowserSnapshotEngine { const existing = this.entries.get(hostContents.id); if (existing) { return existing.snapshotEngine; } const snapshotEngine = new BrowserSnapshotEngine(); const entry = { hostContents, snapshotEngine }; this.entries.set(hostContents.id, entry); hostContents.once("destroyed", () => { if (this.entries.get(hostContents.id) === entry) { this.entries.delete(hostContents.id); } }); return snapshotEngine; } } const hostSnapshotEngines = new HostSnapshotEngineRegistry(); interface WebContentsDebugger { isAttached(): boolean; attach(protocolVersion?: string): void; sendCommand(command: string, params?: Record): Promise; on?( event: "message", listener: (event: unknown, method: string, params?: Record) => void, ): void; on?(event: "detach", listener: () => void): void; } interface ConsoleMessageEmitter { on( event: "console-message", listener: ( event: unknown, level: unknown, message: unknown, line: unknown, sourceId: unknown, ) => void, ): void; once(event: "destroyed", listener: () => void): void; } interface BrowserAutomationWebContents extends ConsoleMessageEmitter { readonly id: number; readonly debugger: WebContentsDebugger; getURL(): string; getTitle(): string; canGoBack(): boolean; canGoForward(): boolean; isLoading(): boolean; isDestroyed(): boolean; executeJavaScript(code: string): Promise; loadURL(url: string): Promise; goBack(): void; goForward(): void; reload(): void; capturePage(rect?: Rectangle, options?: { stayHidden?: boolean }): Promise; invalidate(): void; sendInputEvent(event: IsolatedKeyboardInputEvent): void; } export function adaptWebContents(contents: BrowserAutomationWebContents): TabContents { const contentsId = contents.id; observeConsoleMessages(contents, contentsId); const cdpQueue = getCdpQueue(contentsId); const dialogMonitor = getDialogMonitor(contents, contentsId, cdpQueue); return { id: contentsId, getURL: () => contents.getURL(), getTitle: () => contents.getTitle(), canGoBack: () => contents.canGoBack(), canGoForward: () => contents.canGoForward(), isLoading: () => contents.isLoading(), isDestroyed: () => contents.isDestroyed(), executeJavaScript: (code: string) => contents.executeJavaScript(code), loadURL: (url: string) => contents.loadURL(url), goBack: () => contents.goBack(), goForward: () => contents.goForward(), reload: () => contents.reload(), capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions), invalidate: () => contents.invalidate(), sendInputEvent: (event) => contents.sendInputEvent(event), getConsoleMessages: () => consoleMessagesByContentsId.get(contentsId) ?? [], captureDialogs: (task) => dialogMonitor.capture(task), sendDebugCommand: (command: string, params?: Record) => cdpQueue.run(async () => { if (!contents.debugger.isAttached()) { contents.debugger.attach("1.3"); } return contents.debugger.sendCommand(command, params ?? {}); }), }; } function getCdpQueue(contentsId: number): CdpSessionQueue { const existing = cdpQueuesByContentsId.get(contentsId); if (existing) { return existing; } const queue = new CdpSessionQueue(); cdpQueuesByContentsId.set(contentsId, queue); return queue; } function observeConsoleMessages(contents: BrowserAutomationWebContents, contentsId: number): void { if (observedContentsIds.has(contentsId)) { return; } observedContentsIds.add(contentsId); contents.on("console-message", (_event, level, message, line, sourceId) => { const entry = normalizeConsoleMessage({ level, message, line, sourceId }); const messages = consoleMessagesByContentsId.get(contentsId) ?? []; messages.push(entry); consoleMessagesByContentsId.set(contentsId, messages.slice(-MAX_CONSOLE_MESSAGES_PER_TAB)); }); contents.once("destroyed", () => { observedContentsIds.delete(contentsId); consoleMessagesByContentsId.delete(contentsId); cdpQueuesByContentsId.delete(contentsId); dialogMonitorsByContentsId.delete(contentsId); }); } function getDialogMonitor( contents: BrowserAutomationWebContents, contentsId: number, cdpQueue: CdpSessionQueue, ): DialogMonitor { const existing = dialogMonitorsByContentsId.get(contentsId); if (existing) { return existing; } const monitor = new DialogMonitor(contents, contentsId, cdpQueue); dialogMonitorsByContentsId.set(contentsId, monitor); return monitor; } class DialogMonitor { private enabled = false; private listenerRegistered = false; private detachGeneration = 0; private readonly activeCollectors: DialogCollector[] = []; public constructor( private readonly contents: BrowserAutomationWebContents, private readonly contentsId: number, private readonly cdpQueue: CdpSessionQueue, ) {} public async capture( task: () => Promise, ): Promise<{ result: T; dialogs: BrowserAutomationDialogEvent[] }> { const collector: DialogCollector = { dialogs: [] }; const setupDetachGeneration = this.detachGeneration; try { await this.enable(); await this.installPromptShim(); } catch (error) { if (this.contents.isDestroyed() || this.detachGeneration !== setupDetachGeneration) { throw error; } console.warn("[browser-automation] Dialog capture unavailable; running command without it", { contentsId: this.contentsId, error, }); return { result: await task(), dialogs: [] }; } this.activeCollectors.push(collector); try { const result = await task(); this.recordPromptShimDialogs(await this.drainPromptShim()); return { result, dialogs: collector.dialogs }; } finally { const index = this.activeCollectors.indexOf(collector); if (index >= 0) { this.activeCollectors.splice(index, 1); } if (this.activeCollectors.length === 0) { await this.restorePromptShim(); } } } private async enable(): Promise { if (this.enabled) { return; } if (!this.contents.debugger.on) { return; } if (!this.listenerRegistered) { this.listenerRegistered = true; this.contents.debugger.on("message", (_event, method, params) => { if (method !== "Page.javascriptDialogOpening") { return; } if (this.activeCollectors.length === 0) { return; } void this.handleOpening(params ?? {}); }); this.contents.debugger.on("detach", () => { this.enabled = false; this.detachGeneration += 1; }); } await this.sendDebugCommand("Page.enable"); this.enabled = true; } private async handleOpening(params: Record): Promise { const event = handledDialogEvent(params); for (const collector of this.activeCollectors) { this.recordDialogs(collector, [event]); } await this.sendDialogResponseCommand("Page.handleJavaScriptDialog", { accept: dialogAcceptValue(event.type), }); } private async installPromptShim(): Promise { await this.sendDebugCommand("Runtime.evaluate", { expression: promptShimInstallScript(), returnByValue: true, }); } private async drainPromptShim(): Promise { try { const result = (await this.sendDebugCommand("Runtime.evaluate", { expression: promptShimDrainScript(), returnByValue: true, })) as { result?: { value?: unknown } }; return parsePromptShimDialogs(result.result?.value); } catch { return []; } } private async restorePromptShim(): Promise { try { await this.sendDebugCommand("Runtime.evaluate", { expression: promptShimRestoreScript(), returnByValue: true, }); } catch { // Navigation can destroy the execution context before cleanup runs; the next page has no shim. } } private recordDialogs(collector: DialogCollector, dialogs: BrowserAutomationDialogEvent[]): void { for (const dialog of dialogs) { if (collector.dialogs.length >= MAX_DIALOGS_PER_COMMAND) { return; } collector.dialogs.push(dialog); } } private recordPromptShimDialogs(dialogs: BrowserAutomationDialogEvent[]): void { for (const collector of this.activeCollectors) { this.recordDialogs(collector, dialogs); } } private async sendDebugCommand( command: string, params?: Record, ): Promise { return this.cdpQueue.run(async () => { if (!this.contents.debugger.isAttached()) { this.contents.debugger.attach("1.3"); } return this.contents.debugger.sendCommand(command, params ?? {}); }); } private async sendDialogResponseCommand( command: string, params?: Record, ): Promise { // Dialogs can block the CDP command that opened them, so the unblocker must not wait behind // the per-tab command queue. if (!this.contents.debugger.isAttached()) { this.contents.debugger.attach("1.3"); } return this.contents.debugger.sendCommand(command, params ?? {}); } } interface DialogCollector { dialogs: BrowserAutomationDialogEvent[]; } function parsePromptShimDialogs(value: unknown): BrowserAutomationDialogEvent[] { if (!Array.isArray(value)) { return []; } return value.flatMap((entry): BrowserAutomationDialogEvent[] => { if (!entry || typeof entry !== "object") { return []; } const record = entry as Record; if (record.type !== "prompt" || record.action !== "dismissed") { return []; } return [ { type: "prompt", message: typeof record.message === "string" ? record.message : "", ...(typeof record.defaultValue === "string" ? { defaultValue: record.defaultValue } : {}), action: "dismissed", timestamp: typeof record.timestamp === "number" ? record.timestamp : Date.now(), }, ]; }); } function normalizeConsoleMessage(input: { level: unknown; message: unknown; line: unknown; sourceId: unknown; }): BrowserAutomationConsoleLogEntry { return { level: typeof input.level === "string" ? input.level : String(input.level ?? "log"), message: typeof input.message === "string" ? input.message : String(input.message ?? ""), ...(typeof input.sourceId === "string" && input.sourceId.length > 0 ? { source: input.sourceId } : {}), ...(typeof input.line === "number" ? { line: input.line } : {}), timestamp: Date.now(), }; } function createRegistry(hostWebContentsId: number): BrowserRegistry { return { listRegisteredBrowserIds: listRegisteredPaseoBrowserIds, listRegisteredBrowserIdsForWorkspace: listRegisteredPaseoBrowserIdsForWorkspace, getTabContents(browserId: string): TabContents | null { const contents = getPaseoBrowserWebContentsForHostWindow(browserId, hostWebContentsId); return contents ? adaptWebContents(contents) : null; }, getBrowserWorkspaceId: getPaseoBrowserWorkspaceId, getWorkspaceActiveBrowserId(workspaceId: string): string | null { return getWorkspaceActivePaseoBrowserIdForHostWindow(workspaceId, hostWebContentsId); }, }; } export function registerBrowserAutomationIpc(options?: { ipc?: IpcHandlerRegistry }): void { const ipc = options?.ipc ?? ipcMain; ipc.handle("paseo:browser:execute-automation-command", async (event, rawRequest: unknown) => { const hostContents = (event as { sender?: HostWebContents }).sender; const hostWebContentsId = hostContents?.id; if (!hostContents || typeof hostWebContentsId !== "number") { return { requestId: readRequestId(rawRequest), ok: false as const, error: { code: "browser_unsupported" as const, message: "Browser automation requires a host window.", }, }; } const registry = createRegistry(hostWebContentsId); const parsed = BrowserAutomationExecuteRequestSchema.safeParse(rawRequest); if (!parsed.success) { return { requestId: readRequestId(rawRequest), ok: false as const, error: { code: "browser_unsupported" as const, message: `Invalid automation request: ${parsed.error.message}`, retryable: false, }, }; } return executeAutomationCommand(parsed.data, registry, { snapshotEngine: hostSnapshotEngines.get(hostContents), }); }); } function readRequestId(rawRequest: unknown): string { if (typeof rawRequest !== "object" || rawRequest === null || Array.isArray(rawRequest)) { return "unknown"; } const requestId = (rawRequest as Record).requestId; return typeof requestId === "string" && requestId.length > 0 ? requestId : "unknown"; }