/** * Desktop side of the in-app browser preview. * * Hosts per-tab Chromium WebContents references (the actual <webview> * elements live in the renderer; we only attach listeners and forward state * here). Single layer-scoped browser session partition. */import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts";import type { DesktopPreviewAnnotationTheme, DesktopPreviewAutomationStatus, DesktopPreviewColorScheme, DesktopPreviewFavicon, DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, PreviewAnnotationSubmissionResult, DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, DesktopPreviewScreenshotArtifact, DesktopPreviewTabDefaults, PreviewAutomationClickInput, PreviewAutomationActionEvent, PreviewAutomationConsoleEntry, PreviewAutomationEvaluateInput, PreviewAutomationPressInput, PreviewAutomationNetworkEntry, PreviewAutomationScrollInput, PreviewAutomationSnapshot, PreviewAutomationTypeInput, PreviewAutomationWaitForInput,} from "@t3tools/contracts";import { HostProcessPlatform } from "@t3tools/shared/hostProcess";import { normalizePreviewUrl } from "@t3tools/shared/preview";import { BrowserWindow, ClipboardItem, type Session, clipboard, nativeImage, shell, webContents,} from "electron";import * as Cause from "effect/Cause";import * as Clock from "effect/Clock";import * as Context from "effect/Context";import * as DateTime from "effect/DateTime";import * as Deferred from "effect/Deferred";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";import * as FileSystem from "effect/FileSystem";import * as Fiber from "effect/Fiber";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as Ref from "effect/Ref";import * as Schema from "effect/Schema";import * as Semaphore from "effect/Semaphore";import * as Schedule from "effect/Schedule";import * as Scope from "effect/Scope";import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";import * as BrowserSession from "./BrowserSession.ts";import { ANNOTATION_CAPTURED_CHANNEL, ANNOTATION_THEME_CHANNEL, CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, START_PICK_CHANNEL,} from "./GuestProtocol.ts";import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts";import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts";import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; export type PreviewNavStatus = | { kind: "Idle" } | { kind: "Loading"; url: string; title: string } | { kind: "Success"; url: string; title: string } | { kind: "LoadFailed"; url: string; title: string; code: number; description: string; }; export interface PreviewTabState { tabId: string; webContentsId: number | null; navStatus: PreviewNavStatus; canGoBack: boolean; canGoForward: boolean; zoomFactor: number; pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; /** User intent to silence this tab. Re-applied to each guest that attaches. */ audioMuted: boolean; /** Observed from Chromium. Stays true while a muted tab keeps playing. */ audible: boolean; controller: "human" | "agent" | "none"; favicon?: DesktopPreviewFavicon; updatedAt: string;} /** Discrete zoom levels mirroring Chrome's preset list. */const ZOOM_LEVELS: ReadonlyArray<number> = [ 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0,]; const DEFAULT_ZOOM_FACTOR = 1.0;const ZOOM_EPSILON = 0.001;const MAX_EVALUATION_BYTES = 64_000;const MAX_VISIBLE_TEXT_LENGTH = 20_000;const MAX_INTERACTIVE_ELEMENTS = 200;/** * A `[role]` container's innerText is its whole subtree, which turned one * snapshot's element list into 60 KB of repeated page text. Names are labels, * not content, so cap them where they are read. */const MAX_INTERACTIVE_ELEMENT_NAME_LENGTH = 200;const MAX_SCREENSHOT_WIDTH = 1280;/** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */const RECORDING_ARM_GRACE_MS = 10_000;const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12);const PICTURE_IN_PICTURE_JPEG_QUALITY = 80;/** * Cold guests can reject capturePage with UnknownVizError or never settle it. * Bound each attempt so snapshots release control even when Chromium stalls. */const CAPTURE_PAGE_RETRY_ATTEMPTS = 3;const CAPTURE_PAGE_RETRY_DELAY_MS = 120;const CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS = 1_000;const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480;const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320;const PICTURE_IN_PICTURE_MIN_WIDTH = 240;const PICTURE_IN_PICTURE_MIN_HEIGHT = 160;const PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON = 0.002;const DIAGNOSTIC_BUFFER_LIMIT = 200;const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80;const AGENT_CURSOR_MOVE_MS = 160;const AGENT_CURSOR_CLICK_LEAD_MS = 40;const requestRecordingCaptureExpression = (tabId: string): string => `globalThis[${JSON.stringify(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER)}]?.(${JSON.stringify(tabId)}) === true`;const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { colorScheme: "light", radius: "0.625rem", background: "white", foreground: "oklch(0.269 0 0)", popover: "white", popoverForeground: "oklch(0.269 0 0)", primary: "oklch(0.488 0.217 264)", primaryForeground: "white", muted: "rgb(0 0 0 / 4%)", mutedForeground: "oklch(0.556 0 0)", accent: "rgb(0 0 0 / 4%)", accentForeground: "oklch(0.269 0 0)", border: "rgb(0 0 0 / 8%)", input: "rgb(0 0 0 / 10%)", ring: "oklch(0.488 0.217 264)", fontSans: "system-ui, sans-serif", fontMono: "ui-monospace, monospace",}; const buildPreviewPictureInPictureDataUrl = (): string => { const html = `<!doctype html><html> <head> <meta charset="utf-8"> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'" > <meta name="color-scheme" content="dark"> <style> html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #111; } body { display: grid; place-items: center; } img { width: 100%; height: 100%; object-fit: contain; user-select: none; -webkit-user-drag: none; } </style> </head> <body> <img id="preview-frame" alt="Live browser preview"> <script> const frame = document.getElementById("preview-frame"); window.previewPictureInPicture.onFrame((next) => { frame.src = "data:image/jpeg;base64," + next.data; }); </script> </body></html>`; return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;}; export const fitPictureInPictureContentSize = ( current: ReadonlyArray<number>, aspectRatio: number,): readonly [width: number, height: number] => { const currentWidth = Math.max(1, current[0] ?? PICTURE_IN_PICTURE_INITIAL_WIDTH); const currentHeight = Math.max(1, current[1] ?? PICTURE_IN_PICTURE_INITIAL_HEIGHT); const currentArea = currentWidth * currentHeight; let width = Math.sqrt(currentArea * aspectRatio); let height = width / aspectRatio; const minimumScale = Math.max( 1, PICTURE_IN_PICTURE_MIN_WIDTH / width, PICTURE_IN_PICTURE_MIN_HEIGHT / height, ); width *= minimumScale; height *= minimumScale; return [Math.round(width), Math.round(height)];}; export const recordingFileExtension = (mimeType: string): string => { const subtype = mimeType.split(";", 1)[0]?.trim().toLowerCase().split("/")[1] ?? ""; const extension = subtype.replace(/^x-/, "").replace(/[^a-z0-9]/g, ""); return extension || "video";}; const artifactSiteSlug = (rawUrl: string): string => { try { const url = new URL(rawUrl); const slug = url.hostname .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, MAX_ARTIFACT_SITE_SLUG_LENGTH) .replace(/-+$/g, ""); return slug || "site"; } catch { return "site"; }}; interface CdpEvaluationResult { readonly result?: { readonly value?: unknown; readonly description?: string; }; readonly exceptionDetails?: { readonly text?: string; readonly exception?: { readonly description?: string }; };} export const PreviewAutomationSelectorKind = Schema.Literals([ "focused-element", "selector", "locator",]);export type PreviewAutomationSelectorKind = typeof PreviewAutomationSelectorKind.Type; export const PreviewAutomationEvaluationDetailKind = Schema.Literals([ "exception-description", "exception-text", "unknown",]);export type PreviewAutomationEvaluationDetailKind = typeof PreviewAutomationEvaluationDetailKind.Type; const previewAutomationEvaluationDetail = (exceptionDetails: unknown) => { if (typeof exceptionDetails !== "object" || exceptionDetails === null) { return { detailKind: "unknown" as const }; } const details = exceptionDetails as Record<string, unknown>; const exception = details["exception"]; const description = typeof exception === "object" && exception !== null && typeof (exception as Record<string, unknown>)["description"] === "string" ? (exception as Record<string, unknown>)["description"] : undefined; if (typeof description === "string" && description.length > 0) { return { detailKind: "exception-description" as const, detail: description }; } const text = details["text"]; if (typeof text === "string" && text.length > 0) { return { detailKind: "exception-text" as const, detail: text }; } return { detailKind: "unknown" as const };}; const previewAutomationTargetLabel = ( selectorKind: PreviewAutomationSelectorKind, selectorLength?: number,) => selectorKind === "focused-element" ? "the focused element" : `${selectorKind} (${selectorLength ?? 0} characters)`; interface PreviewOperationContext { readonly operation: string; readonly tabId?: string; readonly webContentsId?: number; readonly artifactPath?: string;} const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { if (typeof value !== "object" || value === null) return null; const rect = value as Record<string, unknown>; const x = rect["x"]; const y = rect["y"]; const width = rect["width"]; const height = rect["height"]; if ( typeof x !== "number" || !Number.isFinite(x) || typeof y !== "number" || !Number.isFinite(y) || typeof width !== "number" || !Number.isFinite(width) || typeof height !== "number" || !Number.isFinite(height) || width <= 0 || height <= 0 ) { return null; } return { x: Math.max(0, Math.floor(x)), y: Math.max(0, Math.floor(y)), width: Math.max(1, Math.ceil(width)), height: Math.max(1, Math.ceil(height)), };}; /** `capturePage` never settles when the guest's compositor is wedged. */const ANNOTATION_SCREENSHOT_TIMEOUT = "5 seconds"; /** * Crops the guest for a picked annotation. A stalled `capturePage` resolves to * `null` after the timeout: the annotation is still sendable without its * screenshot, and the pick session must settle either way. */const captureAnnotationScreenshot = ( tabId: string, wc: Electron.WebContents, cropRect: PreviewAnnotationRect | null,): Effect.Effect<PreviewAnnotationPayload["screenshot"], PreviewManagerError> => Effect.tryPromise({ // The unused abort signal is what makes this interruptible, and therefore // what lets the timeout below fire. Drop the parameter and a stalled // capture strands the pick session again. try: (_signal) => wc.capturePage( cropRect ? { x: cropRect.x, y: cropRect.y, width: cropRect.width, height: cropRect.height, } : undefined, ), catch: (cause) => new PreviewOperationError({ operation: "captureAnnotationScreenshot", tabId, webContentsId: wc.id, cause, }), }).pipe( Effect.map((image): PreviewAnnotationPayload["screenshot"] => { const size = image.getSize(); return { dataUrl: image.toDataURL(), width: size.width, height: size.height, cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height }, }; }), Effect.timeoutOption(ANNOTATION_SCREENSHOT_TIMEOUT), Effect.flatMap((screenshot) => Option.isSome(screenshot) ? Effect.succeed(screenshot.value) : Effect.logWarning("preview annotation screenshot timed out").pipe( Effect.annotateLogs({ tabId, webContentsId: wc.id }), Effect.as(null), ), ), ); const findZoomStep = (current: number): number => { const index = ZOOM_LEVELS.findIndex( (level) => Math.abs(level - current) < ZOOM_EPSILON || level > current, ); if (index < 0) return ZOOM_LEVELS.length - 1; return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1;}; /** * Clamp a client-supplied zoom factor onto the discrete ladder. The setting is * chosen from the same ladder, but it arrives over IPC from a schema that only * guarantees a positive number, so an out-of-band value snaps to the nearest * step rather than leaving the guest at a zoom the zoom controls can't reach. */const normalizeZoomFactor = (value: number | undefined): number => { if (value === undefined || !Number.isFinite(value)) return DEFAULT_ZOOM_FACTOR; let closest = ZOOM_LEVELS[0]!; for (const level of ZOOM_LEVELS) { if (Math.abs(level - value) < Math.abs(closest - value)) closest = level; } return closest;}; const nextZoomLevel = (current: number, direction: "in" | "out"): number => { const step = findZoomStep(current); if (direction === "in") { return ZOOM_LEVELS[Math.min(step + 1, ZOOM_LEVELS.length - 1)] ?? current; } return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current;}; type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect<void>;type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect<void>; type PreviewInputSignal = | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } | { readonly kind: "key"; readonly key: string; readonly code: string }; interface ManagedListeners { readonly attachmentId: symbol; readonly cancelFaviconCapture: () => void; readonly scope: Scope.Closeable; readonly webContents: Electron.WebContents;} type FrameCaptureConsumer = "picture-in-picture" | "recording"; interface FrameCaptureSession { readonly scope: Scope.Closeable | null; readonly consumers: ReadonlySet<FrameCaptureConsumer>; readonly unthrottledWebContentsIds: ReadonlySet<number>; readonly lastPictureInPictureFrame: Buffer | null;} interface PictureInPictureSession { readonly window: BrowserWindow; readonly webContentsId: number; readonly ready: Deferred.Deferred<void, PreviewManagerError>; readonly initializationScope: Scope.Closeable;} /** The tab whose frame the next `getDisplayMedia()` request is allowed to capture. */interface PendingRecording { readonly tabId: string; readonly webContents: Electron.WebContents; readonly requestingFrameTreeNodeId: number; readonly armedAtMillis: number;} interface PickSession { readonly cancel: Effect.Effect<void>;} interface BrowserControlSession { readonly webContentsId: number; // Pins the WebContents' Debugger wrapper for the session's lifetime. // Electron's Debugger is GC-managed but registered with Chromium as a raw // DevToolsAgentHostClient pointer; collecting it while attached crashes the // browser process (electron/electron#53376). Detach must also go through // this reference: `wc.debugger` throws once the WebContents is destroyed. readonly debugger: Electron.Debugger; readonly semaphore: Semaphore.Semaphore; readonly scope: Scope.Closeable; readonly onMessage: ( event: Electron.Event, method: string, params: Record<string, unknown>, ) => void;} interface BrowserDiagnostics { readonly consoleEntries: ReadonlyArray<PreviewAutomationConsoleEntry>; readonly networkEntries: ReadonlyArray<PreviewAutomationNetworkEntry>; readonly requests: ReadonlyMap<string, { url: string; method: string }>;} type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect<void>; interface ExpectedAgentInput { readonly signal: PreviewInputSignal; readonly expiresAt: number;} /** * Protocols a preview page may open in a real popup window. * * `about:blank` stays out: Chromium skips browser-side navigation for it, so the * child copies the guest's `contextIsolation: false` preferences and Electron * gives no way to override them. Those popups keep loading in the preview tab. * * Deliberately not `ElectronShell.parseSafeExternalUrl`: that also admits * `vscode://vscode-remote/...` deep links, which belong in `shell.openExternal` * and not in a window spawned by a third-party page in the preview. */Lines 1–500 of 4738. Load more
/** * Desktop side of the in-app browser preview. * * Hosts per-tab Chromium WebContents references (the actual <webview> * elements live in the renderer; we only attach listeners and forward state * here). Single layer-scoped browser session partition. */ import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; import type { DesktopPreviewAnnotationTheme, DesktopPreviewAutomationStatus, DesktopPreviewColorScheme, DesktopPreviewFavicon, DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, PreviewAnnotationSubmissionResult, DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, DesktopPreviewScreenshotArtifact, DesktopPreviewTabDefaults, PreviewAutomationClickInput, PreviewAutomationActionEvent, PreviewAutomationConsoleEntry, PreviewAutomationEvaluateInput, PreviewAutomationPressInput, PreviewAutomationNetworkEntry, PreviewAutomationScrollInput, PreviewAutomationSnapshot, PreviewAutomationTypeInput, PreviewAutomationWaitForInput, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { BrowserWindow, ClipboardItem, type Session, clipboard, nativeImage, shell, webContents, } from "electron"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { ANNOTATION_CAPTURED_CHANNEL, ANNOTATION_THEME_CHANNEL, CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts"; import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts"; import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; export type PreviewNavStatus = | { kind: "Idle" } | { kind: "Loading"; url: string; title: string } | { kind: "Success"; url: string; title: string } | { kind: "LoadFailed"; url: string; title: string; code: number; description: string; }; export interface PreviewTabState { tabId: string; webContentsId: number | null; navStatus: PreviewNavStatus; canGoBack: boolean; canGoForward: boolean; zoomFactor: number; pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; /** User intent to silence this tab. Re-applied to each guest that attaches. */ audioMuted: boolean; /** Observed from Chromium. Stays true while a muted tab keeps playing. */ audible: boolean; controller: "human" | "agent" | "none"; favicon?: DesktopPreviewFavicon; updatedAt: string; } /** Discrete zoom levels mirroring Chrome's preset list. */ const ZOOM_LEVELS: ReadonlyArray<number> = [ 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, ]; const DEFAULT_ZOOM_FACTOR = 1.0; const ZOOM_EPSILON = 0.001; const MAX_EVALUATION_BYTES = 64_000; const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_INTERACTIVE_ELEMENTS = 200; /** * A `[role]` container's innerText is its whole subtree, which turned one * snapshot's element list into 60 KB of repeated page text. Names are labels, * not content, so cap them where they are read. */ const MAX_INTERACTIVE_ELEMENT_NAME_LENGTH = 200; const MAX_SCREENSHOT_WIDTH = 1280; /** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */ const RECORDING_ARM_GRACE_MS = 10_000; const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; /** * Cold guests can reject capturePage with UnknownVizError or never settle it. * Bound each attempt so snapshots release control even when Chromium stalls. */ const CAPTURE_PAGE_RETRY_ATTEMPTS = 3; const CAPTURE_PAGE_RETRY_DELAY_MS = 120; const CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS = 1_000; const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320; const PICTURE_IN_PICTURE_MIN_WIDTH = 240; const PICTURE_IN_PICTURE_MIN_HEIGHT = 160; const PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON = 0.002; const DIAGNOSTIC_BUFFER_LIMIT = 200; const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; const requestRecordingCaptureExpression = (tabId: string): string => `globalThis[${JSON.stringify(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER)}]?.(${JSON.stringify(tabId)}) === true`; const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { colorScheme: "light", radius: "0.625rem", background: "white", foreground: "oklch(0.269 0 0)", popover: "white", popoverForeground: "oklch(0.269 0 0)", primary: "oklch(0.488 0.217 264)", primaryForeground: "white", muted: "rgb(0 0 0 / 4%)", mutedForeground: "oklch(0.556 0 0)", accent: "rgb(0 0 0 / 4%)", accentForeground: "oklch(0.269 0 0)", border: "rgb(0 0 0 / 8%)", input: "rgb(0 0 0 / 10%)", ring: "oklch(0.488 0.217 264)", fontSans: "system-ui, sans-serif", fontMono: "ui-monospace, monospace", }; const buildPreviewPictureInPictureDataUrl = (): string => { const html = `<!doctype html> <html> <head> <meta charset="utf-8"> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'" > <meta name="color-scheme" content="dark"> <style> html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #111; } body { display: grid; place-items: center; } img { width: 100%; height: 100%; object-fit: contain; user-select: none; -webkit-user-drag: none; } </style> </head> <body> <img id="preview-frame" alt="Live browser preview"> <script> const frame = document.getElementById("preview-frame"); window.previewPictureInPicture.onFrame((next) => { frame.src = "data:image/jpeg;base64," + next.data; }); </script> </body> </html>`; return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; }; export const fitPictureInPictureContentSize = ( current: ReadonlyArray<number>, aspectRatio: number, ): readonly [width: number, height: number] => { const currentWidth = Math.max(1, current[0] ?? PICTURE_IN_PICTURE_INITIAL_WIDTH); const currentHeight = Math.max(1, current[1] ?? PICTURE_IN_PICTURE_INITIAL_HEIGHT); const currentArea = currentWidth * currentHeight; let width = Math.sqrt(currentArea * aspectRatio); let height = width / aspectRatio; const minimumScale = Math.max( 1, PICTURE_IN_PICTURE_MIN_WIDTH / width, PICTURE_IN_PICTURE_MIN_HEIGHT / height, ); width *= minimumScale; height *= minimumScale; return [Math.round(width), Math.round(height)]; }; export const recordingFileExtension = (mimeType: string): string => { const subtype = mimeType.split(";", 1)[0]?.trim().toLowerCase().split("/")[1] ?? ""; const extension = subtype.replace(/^x-/, "").replace(/[^a-z0-9]/g, ""); return extension || "video"; }; const artifactSiteSlug = (rawUrl: string): string => { try { const url = new URL(rawUrl); const slug = url.hostname .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, MAX_ARTIFACT_SITE_SLUG_LENGTH) .replace(/-+$/g, ""); return slug || "site"; } catch { return "site"; } }; interface CdpEvaluationResult { readonly result?: { readonly value?: unknown; readonly description?: string; }; readonly exceptionDetails?: { readonly text?: string; readonly exception?: { readonly description?: string }; }; } export const PreviewAutomationSelectorKind = Schema.Literals([ "focused-element", "selector", "locator", ]); export type PreviewAutomationSelectorKind = typeof PreviewAutomationSelectorKind.Type; export const PreviewAutomationEvaluationDetailKind = Schema.Literals([ "exception-description", "exception-text", "unknown", ]); export type PreviewAutomationEvaluationDetailKind = typeof PreviewAutomationEvaluationDetailKind.Type; const previewAutomationEvaluationDetail = (exceptionDetails: unknown) => { if (typeof exceptionDetails !== "object" || exceptionDetails === null) { return { detailKind: "unknown" as const }; } const details = exceptionDetails as Record<string, unknown>; const exception = details["exception"]; const description = typeof exception === "object" && exception !== null && typeof (exception as Record<string, unknown>)["description"] === "string" ? (exception as Record<string, unknown>)["description"] : undefined; if (typeof description === "string" && description.length > 0) { return { detailKind: "exception-description" as const, detail: description }; } const text = details["text"]; if (typeof text === "string" && text.length > 0) { return { detailKind: "exception-text" as const, detail: text }; } return { detailKind: "unknown" as const }; }; const previewAutomationTargetLabel = ( selectorKind: PreviewAutomationSelectorKind, selectorLength?: number, ) => selectorKind === "focused-element" ? "the focused element" : `${selectorKind} (${selectorLength ?? 0} characters)`; interface PreviewOperationContext { readonly operation: string; readonly tabId?: string; readonly webContentsId?: number; readonly artifactPath?: string; } const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { if (typeof value !== "object" || value === null) return null; const rect = value as Record<string, unknown>; const x = rect["x"]; const y = rect["y"]; const width = rect["width"]; const height = rect["height"]; if ( typeof x !== "number" || !Number.isFinite(x) || typeof y !== "number" || !Number.isFinite(y) || typeof width !== "number" || !Number.isFinite(width) || typeof height !== "number" || !Number.isFinite(height) || width <= 0 || height <= 0 ) { return null; } return { x: Math.max(0, Math.floor(x)), y: Math.max(0, Math.floor(y)), width: Math.max(1, Math.ceil(width)), height: Math.max(1, Math.ceil(height)), }; }; /** `capturePage` never settles when the guest's compositor is wedged. */ const ANNOTATION_SCREENSHOT_TIMEOUT = "5 seconds"; /** * Crops the guest for a picked annotation. A stalled `capturePage` resolves to * `null` after the timeout: the annotation is still sendable without its * screenshot, and the pick session must settle either way. */ const captureAnnotationScreenshot = ( tabId: string, wc: Electron.WebContents, cropRect: PreviewAnnotationRect | null, ): Effect.Effect<PreviewAnnotationPayload["screenshot"], PreviewManagerError> => Effect.tryPromise({ // The unused abort signal is what makes this interruptible, and therefore // what lets the timeout below fire. Drop the parameter and a stalled // capture strands the pick session again. try: (_signal) => wc.capturePage( cropRect ? { x: cropRect.x, y: cropRect.y, width: cropRect.width, height: cropRect.height, } : undefined, ), catch: (cause) => new PreviewOperationError({ operation: "captureAnnotationScreenshot", tabId, webContentsId: wc.id, cause, }), }).pipe( Effect.map((image): PreviewAnnotationPayload["screenshot"] => { const size = image.getSize(); return { dataUrl: image.toDataURL(), width: size.width, height: size.height, cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height }, }; }), Effect.timeoutOption(ANNOTATION_SCREENSHOT_TIMEOUT), Effect.flatMap((screenshot) => Option.isSome(screenshot) ? Effect.succeed(screenshot.value) : Effect.logWarning("preview annotation screenshot timed out").pipe( Effect.annotateLogs({ tabId, webContentsId: wc.id }), Effect.as(null), ), ), ); const findZoomStep = (current: number): number => { const index = ZOOM_LEVELS.findIndex( (level) => Math.abs(level - current) < ZOOM_EPSILON || level > current, ); if (index < 0) return ZOOM_LEVELS.length - 1; return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1; }; /** * Clamp a client-supplied zoom factor onto the discrete ladder. The setting is * chosen from the same ladder, but it arrives over IPC from a schema that only * guarantees a positive number, so an out-of-band value snaps to the nearest * step rather than leaving the guest at a zoom the zoom controls can't reach. */ const normalizeZoomFactor = (value: number | undefined): number => { if (value === undefined || !Number.isFinite(value)) return DEFAULT_ZOOM_FACTOR; let closest = ZOOM_LEVELS[0]!; for (const level of ZOOM_LEVELS) { if (Math.abs(level - value) < Math.abs(closest - value)) closest = level; } return closest; }; const nextZoomLevel = (current: number, direction: "in" | "out"): number => { const step = findZoomStep(current); if (direction === "in") { return ZOOM_LEVELS[Math.min(step + 1, ZOOM_LEVELS.length - 1)] ?? current; } return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current; }; type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect<void>; type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect<void>; type PreviewInputSignal = | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } | { readonly kind: "key"; readonly key: string; readonly code: string }; interface ManagedListeners { readonly attachmentId: symbol; readonly cancelFaviconCapture: () => void; readonly scope: Scope.Closeable; readonly webContents: Electron.WebContents; } type FrameCaptureConsumer = "picture-in-picture" | "recording"; interface FrameCaptureSession { readonly scope: Scope.Closeable | null; readonly consumers: ReadonlySet<FrameCaptureConsumer>; readonly unthrottledWebContentsIds: ReadonlySet<number>; readonly lastPictureInPictureFrame: Buffer | null; } interface PictureInPictureSession { readonly window: BrowserWindow; readonly webContentsId: number; readonly ready: Deferred.Deferred<void, PreviewManagerError>; readonly initializationScope: Scope.Closeable; } /** The tab whose frame the next `getDisplayMedia()` request is allowed to capture. */ interface PendingRecording { readonly tabId: string; readonly webContents: Electron.WebContents; readonly requestingFrameTreeNodeId: number; readonly armedAtMillis: number; } interface PickSession { readonly cancel: Effect.Effect<void>; } interface BrowserControlSession { readonly webContentsId: number; // Pins the WebContents' Debugger wrapper for the session's lifetime. // Electron's Debugger is GC-managed but registered with Chromium as a raw // DevToolsAgentHostClient pointer; collecting it while attached crashes the // browser process (electron/electron#53376). Detach must also go through // this reference: `wc.debugger` throws once the WebContents is destroyed. readonly debugger: Electron.Debugger; readonly semaphore: Semaphore.Semaphore; readonly scope: Scope.Closeable; readonly onMessage: ( event: Electron.Event, method: string, params: Record<string, unknown>, ) => void; } interface BrowserDiagnostics { readonly consoleEntries: ReadonlyArray<PreviewAutomationConsoleEntry>; readonly networkEntries: ReadonlyArray<PreviewAutomationNetworkEntry>; readonly requests: ReadonlyMap<string, { url: string; method: string }>; } type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect<void>; interface ExpectedAgentInput { readonly signal: PreviewInputSignal; readonly expiresAt: number; } /** * Protocols a preview page may open in a real popup window. * * `about:blank` stays out: Chromium skips browser-side navigation for it, so the * child copies the guest's `contextIsolation: false` preferences and Electron * gives no way to override them. Those popups keep loading in the preview tab. * * Deliberately not `ElectronShell.parseSafeExternalUrl`: that also admits * `vscode://vscode-remote/...` deep links, which belong in `shell.openExternal` * and not in a window spawned by a third-party page in the preview. */