Harden browser capture prep cleanup

This commit is contained in:
Mohamed Boudra
2026-07-03 03:53:53 +02:00
parent 6781521c8e
commit 33ffeb4d42
7 changed files with 397 additions and 43 deletions

View File

@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import {
cancelResidentBrowserWebviewPixelCapture,
clearResidentBrowserWebviewsForTests,
ensureResidentBrowserWebview,
prepareResidentBrowserWebviewForPixelCapture,
@@ -121,4 +122,46 @@ describe("resident browser webviews", () => {
expect(host?.style.width).toBe("1280px");
expect(host?.style.opacity).toBe("0");
});
it("cancels an in-flight pixel capture preparation by request id", async () => {
ensureResidentBrowserWebview({
browserId: "browser-cancel",
url: "https://example.com",
});
const preparation = prepareResidentBrowserWebviewForPixelCapture({
requestId: "prepare-1",
browserId: "browser-cancel",
});
const host = document.getElementById("paseo-browser-resident-webviews");
expect(host?.style.left).toBe("0px");
expect(host?.style.opacity).toBe("1");
await cancelResidentBrowserWebviewPixelCapture({ requestId: "prepare-1" });
await expect(preparation).rejects.toThrow("Browser pixel capture preparation was canceled.");
expect(host?.style.left).toBe("-20000px");
expect(host?.style.width).toBe("1280px");
expect(host?.style.opacity).toBe("0");
});
it("parks the resident host when a prepared browser tab is removed", async () => {
const webview = ensureResidentBrowserWebview({
browserId: "browser-detached",
url: "https://example.com",
});
const preparation = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-detached",
});
const host = document.getElementById("paseo-browser-resident-webviews");
removeResidentBrowserWebview("browser-detached");
expect(webview?.isConnected).toBe(false);
expect(host?.style.left).toBe("-20000px");
expect(host?.style.width).toBe("1280px");
expect(host?.style.opacity).toBe("0");
await restoreResidentBrowserWebviewAfterPixelCapture(preparation);
expect(host?.style.left).toBe("-20000px");
});
});

View File

@@ -6,7 +6,7 @@ const RESIDENT_VIEWPORT_WIDTH = 1280;
const RESIDENT_VIEWPORT_HEIGHT = 800;
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
const activeCapturePreparations = new Map<string, { preparesResidentHost: boolean }>();
const activeCapturePreparations = new Map<string, ActiveCapturePreparation>();
let captureBridgeInstallCount = 0;
let captureBridgeDisposer: (() => void) | null = null;
@@ -16,6 +16,12 @@ interface BrowserWebviewElement extends HTMLElement {
src: string;
}
interface ActiveCapturePreparation {
browserId: string;
requestId?: string;
preparesResidentHost: boolean;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
@@ -120,6 +126,58 @@ async function waitForCapturePaint(webview: HTMLElement): Promise<void> {
webview.getBoundingClientRect();
}
function activeCaptureTokenFor(input: { token?: string; requestId?: string }): string | null {
const token = trimNonEmpty(input.token);
if (token && activeCapturePreparations.has(token)) {
return token;
}
const requestId = trimNonEmpty(input.requestId);
if (!requestId) {
return null;
}
for (const [candidateToken, preparation] of activeCapturePreparations.entries()) {
if (preparation.requestId === requestId) {
return candidateToken;
}
}
return null;
}
function parkResidentHostIfIdle(): void {
if (hasActiveResidentHostPreparation()) {
return;
}
const host = readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID);
if (host instanceof HTMLElement) {
applyResidentHostParkingStyle(host);
}
}
function releaseCapturePreparationToken(token: string): void {
const preparation = activeCapturePreparations.get(token);
if (!preparation) {
return;
}
activeCapturePreparations.delete(token);
if (preparation.preparesResidentHost) {
parkResidentHostIfIdle();
}
}
function releaseCapturePreparationsForBrowser(browserId: string): void {
for (const [token, preparation] of activeCapturePreparations.entries()) {
if (preparation.browserId === browserId) {
activeCapturePreparations.delete(token);
}
}
parkResidentHostIfIdle();
}
function releaseAllCapturePreparations(): void {
activeCapturePreparations.clear();
parkResidentHostIfIdle();
}
export function prepareBrowserWebview(
webview: HTMLElement,
input: { browserId: string; initialUrl?: string | null },
@@ -196,6 +254,7 @@ export function releaseResidentBrowserWebview(browserId: string, webview: HTMLEl
export async function prepareResidentBrowserWebviewForPixelCapture(input: {
browserId: string;
requestId?: string;
}): Promise<{ token: string }> {
const browserId = trimNonEmpty(input.browserId);
if (!browserId) {
@@ -214,19 +273,24 @@ export async function prepareResidentBrowserWebviewForPixelCapture(input: {
const token = `capture-${++nextCapturePreparationId}`;
const preparesResidentHost = webview.parentElement === host;
activeCapturePreparations.set(token, { preparesResidentHost });
const requestId = trimNonEmpty(input.requestId);
activeCapturePreparations.set(token, {
browserId,
...(requestId ? { requestId } : {}),
preparesResidentHost,
});
try {
if (preparesResidentHost) {
applyResidentHostCaptureStyle(host);
applyResidentWebviewStyle(webview);
}
await waitForCapturePaint(webview);
if (!activeCapturePreparations.has(token)) {
throw new Error("Browser pixel capture preparation was canceled.");
}
return { token };
} catch (error) {
activeCapturePreparations.delete(token);
if (!hasActiveResidentHostPreparation()) {
applyResidentHostParkingStyle(host);
}
releaseCapturePreparationToken(token);
throw error;
}
}
@@ -234,24 +298,18 @@ export async function prepareResidentBrowserWebviewForPixelCapture(input: {
export async function restoreResidentBrowserWebviewAfterPixelCapture(input: {
token: string;
}): Promise<void> {
const preparation = activeCapturePreparations.get(input.token);
if (!preparation) {
return;
}
releaseCapturePreparationToken(input.token);
}
activeCapturePreparations.delete(input.token);
if (!preparation.preparesResidentHost || hasActiveResidentHostPreparation()) {
export async function cancelResidentBrowserWebviewPixelCapture(input: {
requestId?: string;
token?: string;
}): Promise<void> {
const token = activeCaptureTokenFor(input);
if (!token) {
return;
}
const ownerDocument = readDocument();
if (!ownerDocument) {
return;
}
const host = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
if (host instanceof HTMLElement) {
applyResidentHostParkingStyle(host);
}
releaseCapturePreparationToken(token);
}
export function installResidentBrowserCaptureBridge(): () => void {
@@ -264,9 +322,13 @@ export function installResidentBrowserCaptureBridge(): () => void {
const disposeRestore = browserBridge?.onRestorePixelCapture?.(
restoreResidentBrowserWebviewAfterPixelCapture,
);
const disposeCancel = browserBridge?.onCancelPixelCapture?.(
cancelResidentBrowserWebviewPixelCapture,
);
captureBridgeDisposer = () => {
disposePrepare?.();
disposeRestore?.();
disposeCancel?.();
};
}
@@ -277,6 +339,7 @@ export function installResidentBrowserCaptureBridge(): () => void {
}
captureBridgeDisposer?.();
captureBridgeDisposer = null;
releaseAllCapturePreparations();
};
}
@@ -288,6 +351,7 @@ export function removeResidentBrowserWebview(browserId: string): void {
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
residentWebviewsByBrowserId.delete(normalizedBrowserId);
releaseCapturePreparationsForBrowser(normalizedBrowserId);
resident?.remove();
}
@@ -296,7 +360,7 @@ export function clearResidentBrowserWebviewsForTests(): void {
webview.remove();
}
residentWebviewsByBrowserId.clear();
activeCapturePreparations.clear();
releaseAllCapturePreparations();
nextCapturePreparationId = 0;
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
}

View File

@@ -149,11 +149,17 @@ export interface DesktopBrowserBridge {
/** Copy element text and/or an image to the system clipboard from main. */
copyElement?: (payload: { text?: string; imageDataUrl?: string }) => Promise<boolean>;
onPrepareForPixelCapture?: (
handler: (input: { browserId: string }) => Promise<DesktopBrowserPixelCapturePreparation>,
handler: (input: {
requestId: string;
browserId: string;
}) => Promise<DesktopBrowserPixelCapturePreparation>,
) => () => void;
onRestorePixelCapture?: (
handler: (input: DesktopBrowserPixelCapturePreparation) => Promise<void>,
) => () => void;
onCancelPixelCapture?: (
handler: (input: { requestId?: string; token?: string }) => Promise<void>,
) => () => void;
}
export interface DesktopInvokeBridge {