Fix browser screenshot capture for parked webviews

This commit is contained in:
Mohamed Boudra
2026-07-03 00:27:35 +02:00
parent b0088303fd
commit 9430a897e8
17 changed files with 1721 additions and 58 deletions
@@ -0,0 +1,239 @@
import type { Rectangle } from "electron";
import { describe, expect, test } from "vitest";
import type { TabImage } from "./service.js";
import { adaptWebContents } from "./ipc.js";
class FakeImage implements TabImage {
public toPNG(): Uint8Array {
return new Uint8Array([137, 80, 78, 71]);
}
public getSize(): { width: number; height: number } {
return { width: 640, height: 480 };
}
}
class FakeDebugger {
public isAttached(): boolean {
return false;
}
public attach(): void {}
public async sendCommand(): Promise<unknown> {
return {};
}
}
class FakeHostWebContents {
public readonly sentMessages: Array<{ channel: string; payload: unknown }> = [];
public destroyed = false;
public constructor(public readonly id: number) {}
public isDestroyed(): boolean {
return this.destroyed;
}
public send(channel: string, payload: unknown): void {
this.sentMessages.push({ channel, payload });
}
}
type IpcListener = (event: unknown, payload: unknown) => void;
class FakeIpcBridge {
private readonly listeners = new Map<string, IpcListener[]>();
public on(channel: string, listener: IpcListener): void {
const listeners = this.listeners.get(channel) ?? [];
listeners.push(listener);
this.listeners.set(channel, listeners);
}
public removeListener(channel: string, listener: IpcListener): void {
const listeners = this.listeners.get(channel) ?? [];
this.listeners.set(
channel,
listeners.filter((candidate) => candidate !== listener),
);
}
public emit(channel: string, payload: unknown): void {
for (const listener of this.listeners.get(channel) ?? []) {
listener({}, payload);
}
}
public listenerCount(channel: string): number {
return this.listeners.get(channel)?.length ?? 0;
}
}
class FakeWebContents {
public readonly debugger = new FakeDebugger();
public readonly consoleMessages: unknown[] = [];
public readonly destroyedListeners: Array<() => void> = [];
public destroyed = false;
public constructor(
public readonly id: number,
public hostWebContents: FakeHostWebContents | null,
) {}
public getURL(): string {
return "https://example.com";
}
public getTitle(): string {
return "Example";
}
public canGoBack(): boolean {
return false;
}
public canGoForward(): boolean {
return false;
}
public isLoading(): boolean {
return false;
}
public isDestroyed(): boolean {
return this.destroyed;
}
public async executeJavaScript(): Promise<unknown> {
return null;
}
public async loadURL(): Promise<void> {}
public goBack(): void {}
public goForward(): void {}
public reload(): void {}
public async capturePage(
_rect?: Rectangle,
_options?: { stayHidden?: boolean },
): Promise<TabImage> {
return new FakeImage();
}
public invalidate(): void {}
public getBackgroundThrottling(): boolean {
return true;
}
public setBackgroundThrottling(): void {}
public on(
event: "console-message",
listener: (
event: unknown,
level: unknown,
message: unknown,
line: unknown,
sourceId: unknown,
) => void,
): void {
this.consoleMessages.push({ event, listener });
}
public once(event: "destroyed", listener: () => void): void {
expect(event).toBe("destroyed");
this.destroyedListeners.push(listener);
}
}
describe("browser automation IPC adapter", () => {
test("prepareForPixelCapture asks the embedder renderer and resolves the ack token", async () => {
const host = new FakeHostWebContents(10);
const contents = new FakeWebContents(20, host);
const ipc = new FakeIpcBridge();
const tab = adaptWebContents(contents, "browser-a", {
ipc,
createRequestId: () => "prepare-1",
});
const preparation = tab.prepareForPixelCapture();
expect(host.sentMessages).toEqual([
{
channel: "paseo:browser:capture-prepare",
payload: { requestId: "prepare-1", browserId: "browser-a" },
},
]);
ipc.emit("paseo:browser:capture-prepared", {
requestId: "other",
ok: true,
token: "wrong-token",
});
expect(ipc.listenerCount("paseo:browser:capture-prepared")).toBe(1);
ipc.emit("paseo:browser:capture-prepared", {
requestId: "prepare-1",
ok: true,
token: "token-a",
});
await expect(preparation).resolves.toEqual({ token: "token-a" });
expect(ipc.listenerCount("paseo:browser:capture-prepared")).toBe(0);
});
test("restorePixelCapture sends the capture token back to the embedder renderer", async () => {
const host = new FakeHostWebContents(10);
const contents = new FakeWebContents(20, host);
const ipc = new FakeIpcBridge();
const tab = adaptWebContents(contents, "browser-a", {
ipc,
createRequestId: () => "restore-1",
});
const restored = tab.restorePixelCapture({ token: "token-a" });
expect(host.sentMessages).toEqual([
{
channel: "paseo:browser:capture-restore",
payload: { requestId: "restore-1", browserId: "browser-a", token: "token-a" },
},
]);
ipc.emit("paseo:browser:capture-restored", { requestId: "restore-1", ok: true });
await expect(restored).resolves.toBeUndefined();
});
test("prepareForPixelCapture rejects when the renderer reports preparation failure", async () => {
const host = new FakeHostWebContents(10);
const contents = new FakeWebContents(20, host);
const ipc = new FakeIpcBridge();
const tab = adaptWebContents(contents, "browser-a", {
ipc,
createRequestId: () => "prepare-1",
});
const preparation = tab.prepareForPixelCapture();
ipc.emit("paseo:browser:capture-prepared", {
requestId: "prepare-1",
ok: false,
message: "renderer could not prep",
});
await expect(preparation).rejects.toThrow("renderer could not prep");
expect(ipc.listenerCount("paseo:browser:capture-prepared")).toBe(0);
});
test("prepareForPixelCapture rejects when the guest has no embedder renderer", async () => {
const contents = new FakeWebContents(20, null);
const tab = adaptWebContents(contents, "browser-a");
await expect(tab.prepareForPixelCapture()).rejects.toThrow(
"Browser host renderer is not available.",
);
});
});
@@ -1,8 +1,8 @@
import type { WebContents } from "electron";
import type { Rectangle } from "electron";
import { ipcMain } from "electron";
import { BrowserAutomationExecuteRequestSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import type { BrowserAutomationConsoleLogEntry } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import type { TabContents, BrowserRegistry } from "./service.js";
import type { TabContents, BrowserRegistry, TabImage } from "./service.js";
import { executeAutomationCommand } from "./service.js";
import {
listRegisteredPaseoBrowserIds,
@@ -13,14 +13,86 @@ import {
} from "../browser-webviews/index.js";
const MAX_CONSOLE_MESSAGES_PER_TAB = 200;
const PIXEL_CAPTURE_BRIDGE_TIMEOUT_MS = 5_000;
const consoleMessagesByContentsId = new Map<number, BrowserAutomationConsoleLogEntry[]>();
const observedContentsIds = new Set<number>();
let nextPixelCaptureBridgeRequest = 0;
interface IpcHandlerRegistry {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void;
}
function adaptWebContents(contents: WebContents): TabContents {
type IpcListener = (event: unknown, payload: unknown) => void;
interface IpcCaptureBridge {
on(channel: string, listener: IpcListener): void;
removeListener(channel: string, listener: IpcListener): void;
}
interface HostWebContents {
readonly id: number;
isDestroyed(): boolean;
send(channel: string, payload: unknown): void;
}
interface WebContentsDebugger {
isAttached(): boolean;
attach(protocolVersion?: string): void;
sendCommand(command: string, params?: Record<string, unknown>): Promise<unknown>;
}
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 hostWebContents: HostWebContents | null;
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;
getBackgroundThrottling(): boolean;
setBackgroundThrottling(allowed: boolean): void;
}
type PixelCaptureBridgeKind = "prepare" | "restore";
interface PixelCaptureBridgeSuccess {
token?: string;
}
interface PixelCaptureBridgeOptions {
ipc?: IpcCaptureBridge;
createRequestId?: () => string;
timeoutMs?: number;
}
export function adaptWebContents(
contents: BrowserAutomationWebContents,
browserId: string,
options?: PixelCaptureBridgeOptions,
): TabContents {
observeConsoleMessages(contents);
return {
id: contents.id,
@@ -35,7 +107,19 @@ function adaptWebContents(contents: WebContents): TabContents {
goBack: () => contents.goBack(),
goForward: () => contents.goForward(),
reload: () => contents.reload(),
capturePage: (options) => contents.capturePage(undefined, options),
capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions),
prepareForPixelCapture: async () => {
const result = await requestPixelCaptureBridge(contents, browserId, "prepare", options);
if (!result.token) {
throw new Error("Browser pixel capture preparation did not return a token.");
}
return { token: result.token };
},
restorePixelCapture: async (preparation) => {
await requestPixelCaptureBridge(contents, browserId, "restore", options, {
token: preparation.token,
});
},
invalidate: () => contents.invalidate(),
isBackgroundThrottlingAllowed: () => contents.getBackgroundThrottling(),
setBackgroundThrottling: (allowed) => contents.setBackgroundThrottling(allowed),
@@ -49,7 +133,101 @@ function adaptWebContents(contents: WebContents): TabContents {
};
}
function observeConsoleMessages(contents: WebContents): void {
function requestPixelCaptureBridge(
contents: BrowserAutomationWebContents,
browserId: string,
kind: PixelCaptureBridgeKind,
options: PixelCaptureBridgeOptions | undefined,
extraPayload?: { token: string },
): Promise<PixelCaptureBridgeSuccess> {
const host = contents.hostWebContents;
if (!host || host.isDestroyed()) {
return Promise.reject(new Error("Browser host renderer is not available."));
}
const ipc = options?.ipc ?? ipcMain;
const requestId =
options?.createRequestId?.() ?? `browser-pixel-capture-${++nextPixelCaptureBridgeRequest}`;
const timeoutMs = options?.timeoutMs ?? PIXEL_CAPTURE_BRIDGE_TIMEOUT_MS;
const requestChannel =
kind === "prepare" ? "paseo:browser:capture-prepare" : "paseo:browser:capture-restore";
const responseChannel =
kind === "prepare" ? "paseo:browser:capture-prepared" : "paseo:browser:capture-restored";
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
ipc.removeListener(responseChannel, listener);
};
const listener: IpcListener = (_event, payload) => {
const response = readPixelCaptureBridgeResponse(payload, requestId);
if (!response) {
return;
}
cleanup();
if (response.ok) {
resolve(response);
} else {
reject(new Error(response.message));
}
};
ipc.on(responseChannel, listener);
timeoutId = setTimeout(() => {
cleanup();
reject(new Error(`Browser pixel capture ${kind} timed out.`));
}, timeoutMs);
try {
host.send(requestChannel, {
requestId,
browserId,
...(extraPayload ? { token: extraPayload.token } : {}),
});
} catch (error) {
cleanup();
reject(error);
}
});
}
function readPixelCaptureBridgeResponse(
payload: unknown,
requestId: string,
): ({ ok: true } & PixelCaptureBridgeSuccess) | { ok: false; message: string } | null {
if (!isRecord(payload)) {
return null;
}
const record = payload;
if (record.requestId !== requestId) {
return null;
}
if (record.ok === true) {
return {
ok: true,
...(typeof record.token === "string" && record.token.length > 0
? { token: record.token }
: {}),
};
}
if (record.ok === false) {
const message =
typeof record.message === "string" && record.message.length > 0
? record.message
: "Browser pixel capture bridge failed.";
return { ok: false, message };
}
return null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function observeConsoleMessages(contents: BrowserAutomationWebContents): void {
if (observedContentsIds.has(contents.id)) {
return;
}
@@ -89,7 +267,7 @@ function createRegistry(): BrowserRegistry {
listRegisteredBrowserIdsForWorkspace: listRegisteredPaseoBrowserIdsForWorkspace,
getTabContents(browserId: string): TabContents | null {
const contents = getPaseoBrowserWebContents(browserId);
return contents ? adaptWebContents(contents) : null;
return contents ? adaptWebContents(contents, browserId) : null;
},
getBrowserWorkspaceId: getPaseoBrowserWorkspaceId,
getWorkspaceActiveBrowserId: getWorkspaceActivePaseoBrowserId,
@@ -7,7 +7,12 @@ import type {
BrowserAutomationExecuteRequest,
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { BrowserSnapshotEngine } from "./snapshot-engine.js";
import type { BrowserRegistry, TabContents, TabImage } from "./service.js";
import type {
BrowserRegistry,
TabContents,
TabImage,
TabPixelCapturePreparation,
} from "./service.js";
import { executeAutomationCommand } from "./service.js";
const BROWSER_A = "11111111-1111-4111-8111-111111111111";
@@ -36,6 +41,9 @@ class FakeTab implements TabContents {
public readonly actions: string[] = [];
public readonly capturedViewports: Array<{ stayHidden?: boolean }> = [];
public readonly debugCommands: Array<{ command: string; params?: Record<string, unknown> }> = [];
public readonly restoredPixelCaptureTokens: string[] = [];
private readonly captureStartWaiters: Array<() => void> = [];
private readonly deferredCaptures: Array<(image: TabImage) => void> = [];
public destroyed = false;
public bodyText = "";
@@ -44,6 +52,9 @@ class FakeTab implements TabContents {
public networkEntries: unknown[] = [];
public consoleMessages: BrowserAutomationConsoleLogEntry[] = [];
public captureNeverPaints = false;
public captureThrows = false;
public deferCaptures = false;
public prepareNeverAcks = false;
public layoutMetrics = {
cssLayoutViewport: { clientWidth: 390, clientHeight: 844 },
cssContentSize: { width: 390, height: 1200 },
@@ -52,6 +63,7 @@ class FakeTab implements TabContents {
public documentNodeId = 1;
public queriedNodeId = 2;
public backgroundThrottlingAllowed = true;
private nextPixelCapturePreparationId = 0;
public constructor(
public readonly id: number,
@@ -116,12 +128,35 @@ class FakeTab implements TabContents {
public async capturePage(options?: { stayHidden?: boolean }): Promise<TabImage> {
this.capturedViewports.push(options ?? {});
this.actions.push("capture");
this.resolveCaptureStartWaiters();
if (this.captureThrows) {
throw new Error("capture failed");
}
if (this.captureNeverPaints) {
return new Promise<never>(() => {});
}
if (this.deferCaptures) {
return new Promise<TabImage>((resolve) => {
this.deferredCaptures.push(resolve);
});
}
return new FakeImage();
}
public async prepareForPixelCapture(): Promise<TabPixelCapturePreparation> {
this.actions.push("prepare");
if (this.prepareNeverAcks) {
return new Promise<never>(() => {});
}
const token = `capture-${++this.nextPixelCapturePreparationId}`;
return { token };
}
public async restorePixelCapture(preparation: TabPixelCapturePreparation): Promise<void> {
this.actions.push(`restore:${preparation.token}`);
this.restoredPixelCaptureTokens.push(preparation.token);
}
public invalidate(): void {
this.actions.push("invalidate");
}
@@ -143,6 +178,7 @@ class FakeTab implements TabContents {
command: string,
params?: Record<string, unknown>,
): Promise<unknown> {
this.actions.push(`debug:${command}`);
this.debugCommands.push({ command, ...(params ? { params } : {}) });
if (command === "Page.getLayoutMetrics") {
return this.layoutMetrics;
@@ -158,6 +194,33 @@ class FakeTab implements TabContents {
}
return {};
}
public waitForCaptureStart(count: number): Promise<void> {
if (this.capturedViewports.length >= count) {
return Promise.resolve();
}
return new Promise((resolve) => {
this.captureStartWaiters.push(() => {
if (this.capturedViewports.length >= count) {
resolve();
}
});
});
}
public finishNextCapture(): void {
const resolve = this.deferredCaptures.shift();
if (!resolve) {
throw new Error("No deferred capture is waiting");
}
resolve(new FakeImage());
}
private resolveCaptureStartWaiters(): void {
for (const waiter of this.captureStartWaiters.splice(0)) {
waiter();
}
}
}
class FakeRegistry implements BrowserRegistry {
@@ -852,9 +915,11 @@ describe("executeAutomationCommand", () => {
});
expect(browser.tab.capturedViewports).toEqual([{ stayHidden: false }]);
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
});
@@ -882,9 +947,11 @@ describe("executeAutomationCommand", () => {
},
});
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
} finally {
@@ -892,6 +959,111 @@ describe("executeAutomationCommand", () => {
}
});
test("screenshot restores capture preparation when viewport capture fails", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.captureThrows = true;
await expect(
browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A },
}),
).rejects.toThrow("capture failed");
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
});
test("screenshot returns no-frame when capture preparation does not ack", async () => {
vi.useFakeTimers();
try {
const browser = new BrowserAutomationHarness();
browser.tab.prepareNeverAcks = true;
const resultPromise = browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A },
});
await vi.advanceTimersByTimeAsync(5_000);
await expect(resultPromise).resolves.toEqual({
requestId: "req-screenshot",
ok: false,
error: {
code: "screenshot_no_frame",
message:
"The browser tab has no painted frame. Focus the tab in the app, then try again.",
retryable: false,
},
});
expect(browser.tab.actions).toEqual(["prepare", "background:true"]);
} finally {
vi.useRealTimers();
}
});
test("overlapping screenshots serialize capture preparation and restore", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.deferCaptures = true;
const first = browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A },
});
await browser.tab.waitForCaptureStart(1);
const second = browser.execute(
{
command: "screenshot",
args: { browserId: BROWSER_A },
},
{ requestId: "req-screenshot-2" },
);
await Promise.resolve();
expect(browser.tab.actions).toEqual(["prepare", "background:false", "invalidate", "capture"]);
browser.tab.finishNextCapture();
await browser.tab.waitForCaptureStart(2);
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
"prepare",
"background:false",
"invalidate",
"capture",
]);
browser.tab.finishNextCapture();
await expect(first).resolves.toMatchObject({ requestId: "req-screenshot", ok: true });
await expect(second).resolves.toMatchObject({ requestId: "req-screenshot-2", ok: true });
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-2",
"background:true",
]);
});
test("screenshot with fullPage captures the page content area through CDP", async () => {
const browser = new BrowserAutomationHarness();
@@ -923,6 +1095,44 @@ describe("executeAutomationCommand", () => {
},
},
]);
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot",
"restore:capture-1",
"background:true",
]);
});
test("screenshot with fullPage restores capture preparation when CDP returns no image", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.fullPageScreenshotData = "";
const result = await browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A, fullPage: true },
});
expect(result).toEqual({
requestId: "req-screenshot",
ok: false,
error: {
code: "browser_unsupported",
message: "browser_screenshot fullPage returned no data",
retryable: false,
},
});
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot",
"restore:capture-1",
"background:true",
]);
});
test("upload resolves workspace files before setting them on the file input", async () => {
@@ -24,6 +24,8 @@ export interface TabContents {
goForward(): void;
reload(): void;
capturePage(options?: TabCapturePageOptions): Promise<TabImage>;
prepareForPixelCapture(): Promise<TabPixelCapturePreparation>;
restorePixelCapture(preparation: TabPixelCapturePreparation): Promise<void>;
invalidate(): void;
isBackgroundThrottlingAllowed(): boolean;
setBackgroundThrottling(allowed: boolean): void;
@@ -31,6 +33,10 @@ export interface TabContents {
sendDebugCommand?(command: string, params?: Record<string, unknown>): Promise<unknown>;
}
export interface TabPixelCapturePreparation {
token: string;
}
export interface TabImage {
toPNG(): Uint8Array;
getSize(): { width: number; height: number };
@@ -124,19 +130,56 @@ async function runSerializedPixelCapture<T>(
}
}
async function capturePaintedViewport(contents: TabContents): Promise<TabImage> {
async function prepareForPixelCapture(contents: TabContents): Promise<TabPixelCapturePreparation> {
try {
return await withPixelCaptureTimeout(contents.prepareForPixelCapture());
} catch {
throw new ScreenshotNoFrameError();
}
}
async function restorePixelCapture(
contents: TabContents,
preparation: TabPixelCapturePreparation,
): Promise<void> {
try {
await withPixelCaptureTimeout(contents.restorePixelCapture(preparation));
} catch {
throw new ScreenshotNoFrameError();
}
}
async function runPreparedPixelCapture<T>(
contents: TabContents,
capture: () => Promise<T>,
): Promise<T> {
return runSerializedPixelCapture(contents, async () => {
const previousBackgroundThrottling = contents.isBackgroundThrottlingAllowed();
contents.setBackgroundThrottling(false);
let preparation: TabPixelCapturePreparation | null = null;
try {
// Offscreen-parked webview guests have no compositor surface for
// capturePage/CDP to copy. The renderer must briefly make the host
// paintable before capture; see ~/.paseo/plans/browser-capture-harness-results.md.
preparation = await prepareForPixelCapture(contents);
contents.setBackgroundThrottling(false);
contents.invalidate();
return await withPixelCaptureTimeout(contents.capturePage({ stayHidden: false }));
return await withPixelCaptureTimeout(capture());
} finally {
contents.setBackgroundThrottling(previousBackgroundThrottling);
try {
if (preparation) {
await restorePixelCapture(contents, preparation);
}
} finally {
contents.setBackgroundThrottling(previousBackgroundThrottling);
}
}
});
}
async function capturePaintedViewport(contents: TabContents): Promise<TabImage> {
return runPreparedPixelCapture(contents, () => contents.capturePage({ stayHidden: false }));
}
function tabInfoFromContents(
browserId: string,
contents: TabContents,
@@ -860,18 +903,21 @@ async function executeFullPageScreenshot(
if (!target.contents.sendDebugCommand) {
return fail(requestId, "browser_unsupported", "browser_screenshot fullPage requires CDP");
}
const metrics = await getCdpLayoutMetrics(target.contents);
const width = metrics.contentWidth;
const height = metrics.contentHeight;
const sendDebugCommand = target.contents.sendDebugCommand.bind(target.contents);
let screenshot: CdpCaptureScreenshotResult;
let width = 0;
let height = 0;
try {
screenshot = (await withPixelCaptureTimeout(
target.contents.sendDebugCommand("Page.captureScreenshot", {
screenshot = await runPreparedPixelCapture(target.contents, async () => {
const metrics = await getCdpLayoutMetrics(target.contents);
width = metrics.contentWidth;
height = metrics.contentHeight;
return (await sendDebugCommand("Page.captureScreenshot", {
format: "png",
captureBeyondViewport: true,
clip: { x: 0, y: 0, width, height, scale: 1 },
}),
)) as CdpCaptureScreenshotResult;
})) as CdpCaptureScreenshotResult;
});
} catch (error) {
if (isScreenshotNoFrameError(error)) {
return screenshotNoFrameFailure(requestId);
+91
View File
@@ -1,6 +1,81 @@
import { contextBridge, ipcRenderer, webUtils } from "electron";
type EventHandler = (payload: unknown) => void;
type BrowserPixelCapturePrepareHandler = (input: {
browserId: string;
}) => Promise<{ token: string }>;
type BrowserPixelCaptureRestoreHandler = (input: { token: string }) => Promise<void>;
let prepareForPixelCaptureHandler: BrowserPixelCapturePrepareHandler | null = null;
let restorePixelCaptureHandler: BrowserPixelCaptureRestoreHandler | null = null;
function readStringField(payload: unknown, key: string): string | null {
if (!isRecord(payload)) {
return null;
}
const value = payload[key];
return typeof value === "string" && value.length > 0 ? value : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
ipcRenderer.on("paseo:browser:capture-prepare", async (_event, payload: unknown) => {
const requestId = readStringField(payload, "requestId");
const browserId = readStringField(payload, "browserId");
if (!requestId || !browserId || !prepareForPixelCaptureHandler) {
ipcRenderer.send("paseo:browser:capture-prepared", {
requestId: requestId ?? "unknown",
ok: false,
message: "Browser pixel capture preparation is unavailable.",
});
return;
}
try {
const preparation = await prepareForPixelCaptureHandler({ browserId });
ipcRenderer.send("paseo:browser:capture-prepared", {
requestId,
ok: true,
token: preparation.token,
});
} catch (error) {
ipcRenderer.send("paseo:browser:capture-prepared", {
requestId,
ok: false,
message: errorMessage(error),
});
}
});
ipcRenderer.on("paseo:browser:capture-restore", async (_event, payload: unknown) => {
const requestId = readStringField(payload, "requestId");
const token = readStringField(payload, "token");
if (!requestId || !token || !restorePixelCaptureHandler) {
ipcRenderer.send("paseo:browser:capture-restored", {
requestId: requestId ?? "unknown",
ok: false,
message: "Browser pixel capture restore is unavailable.",
});
return;
}
try {
await restorePixelCaptureHandler({ token });
ipcRenderer.send("paseo:browser:capture-restored", { requestId, ok: true });
} catch (error) {
ipcRenderer.send("paseo:browser:capture-restored", {
requestId,
ok: false,
message: errorMessage(error),
});
}
});
contextBridge.exposeInMainWorld("paseoDesktop", {
platform: process.platform,
@@ -90,5 +165,21 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
) => ipcRenderer.invoke("paseo:browser:capture-element", browserId, rect),
copyElement: (payload: { text?: string; imageDataUrl?: string }) =>
ipcRenderer.invoke("paseo:browser:copy-element", payload),
onPrepareForPixelCapture: (handler: BrowserPixelCapturePrepareHandler): (() => void) => {
prepareForPixelCaptureHandler = handler;
return () => {
if (prepareForPixelCaptureHandler === handler) {
prepareForPixelCaptureHandler = null;
}
};
},
onRestorePixelCapture: (handler: BrowserPixelCaptureRestoreHandler): (() => void) => {
restorePixelCaptureHandler = handler;
return () => {
if (restorePixelCaptureHandler === handler) {
restorePixelCaptureHandler = null;
}
};
},
},
});