Files
paseo/packages/desktop/src/features/browser-automation/ipc.ts
T
Mohamed BoudraandGitHub eb95a6eea6 Keep desktop browser tabs stable across focus and automation (#2907)
* fix(desktop): preserve browser tabs across focus changes

Electron can replace a guest WebContents when a webview is reparented, which reloads the page and can race automation against a destroyed target. Keep each browser guest on one permanent paintable surface and make user and agent resizing share one canonical viewport.

* fix(desktop): recover browser surfaces after lifecycle changes

* fix(desktop): preserve resident browser lifecycle state

* fix(desktop): seed inactive browser viewport
2026-08-06 00:05:21 +02:00

444 lines
15 KiB
TypeScript

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<number, BrowserAutomationConsoleLogEntry[]>();
const cdpQueuesByContentsId = new Map<number, CdpSessionQueue>();
const dialogMonitorsByContentsId = new Map<number, DialogMonitor>();
const observedContentsIds = new Set<number>();
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<string, unknown>): Promise<unknown>;
on?(
event: "message",
listener: (event: unknown, method: string, params?: Record<string, unknown>) => 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<unknown>;
loadURL(url: string): Promise<void>;
goBack(): void;
goForward(): void;
reload(): void;
capturePage(rect?: Rectangle, options?: { stayHidden?: boolean }): Promise<TabImage>;
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<string, unknown>) =>
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<T>(
task: () => Promise<T>,
): 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<void> {
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<string, unknown>): Promise<void> {
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<void> {
await this.sendDebugCommand("Runtime.evaluate", {
expression: promptShimInstallScript(),
returnByValue: true,
});
}
private async drainPromptShim(): Promise<BrowserAutomationDialogEvent[]> {
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<void> {
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<string, unknown>,
): Promise<unknown> {
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<string, unknown>,
): Promise<unknown> {
// 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<string, unknown>;
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<string, unknown>).requestId;
return typeof requestId === "string" && requestId.length > 0 ? requestId : "unknown";
}