/** * 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 type { DesktopPreviewAnnotationTheme, DesktopPreviewAutomationStatus, DesktopPreviewColorScheme, DesktopPreviewFavicon, DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, PreviewAnnotationSubmissionResult, DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, DesktopPreviewRecordingSource, 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, 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 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;const MAX_SCREENSHOT_WIDTH = 1280;const RECORDING_SOURCE_SIZE_EXPRESSION = "({ width: Math.round(globalThis.innerWidth), height: Math.round(globalThis.innerHeight) })";const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12);const PICTURE_IN_PICTURE_JPEG_QUALITY = 80;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 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",}; export 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)), };}; const captureAnnotationScreenshot = ( tabId: string, wc: Electron.WebContents, cropRect: PreviewAnnotationRect | null,): Effect.Effect<PreviewAnnotationPayload["screenshot"], PreviewManagerError> => Effect.tryPromise({ try: () => 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) => { 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 }, }; }), ); 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 lastPictureInPictureFrame: Buffer | null;} interface PictureInPictureSession { readonly window: BrowserWindow; readonly webContentsId: number; readonly ready: Deferred.Deferred<void, PreviewManagerError>; readonly initializationScope: Scope.Closeable;} interface PickSession { readonly cancel: Effect.Effect<void>;} interface BrowserControlSession { readonly webContentsId: number; 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;} const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ key: string; meta: boolean; shift: boolean; control: boolean;}> = Object.freeze([ // mod+shift+J → preview.toggle { key: "j", meta: true, shift: true, control: false }, // mod+K → command palette { key: "k", meta: true, shift: false, control: false }, // mod+, → settings (macOS convention) { key: ",", meta: true, shift: false, control: false }, // mod+W → close tab/panel { key: "w", meta: true, shift: false, control: false },]); /** * 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. */const POPUP_PROTOCOLS = new Set(["http:", "https:"]); const isPopupUrl = (rawUrl: string): boolean => { try { return POPUP_PROTOCOLS.has(new URL(rawUrl).protocol); } catch { return false; }}; /** * Preferences for a popup a preview page opens. * * A popup is not a webview attach, so the `will-attach-webview` hardening in * `DesktopWindow` never sees it, and an unoverridden child would inherit the * guest's relaxed posture: the picker preload needs `contextIsolation: false` * to share `globalThis` with the previewed page, and no OAuth provider should * get that. The window keeps the opener and the guest session either way. */const POPUP_WINDOW_OPTIONS = { webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, },} satisfies Electron.BrowserWindowConstructorOptions; /** * Decides what a preview page's `window.open` should do. * * `"popup"` opens a real window, which scripted popups need: denying them makes * `window.open()` return `null` (OAuth SDKs report that as a blocked popup), and * navigating the preview tab instead destroys the opener the popup has to * `postMessage` its result back to. * * `target="_blank"` links arrive as a tab disposition and keep loading in the * preview tab, which is what people expect from a link inside a preview. */export const previewWindowOpenAction = (details: { readonly url: string; readonly disposition: Electron.HandlerDetails["disposition"];}): "popup" | "navigate" => details.disposition === "new-window" && isPopupUrl(details.url) ? "popup" : "navigate";Lines 1–500 of 4411. 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 type { DesktopPreviewAnnotationTheme, DesktopPreviewAutomationStatus, DesktopPreviewColorScheme, DesktopPreviewFavicon, DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, PreviewAnnotationSubmissionResult, DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, DesktopPreviewRecordingSource, 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, 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 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; const MAX_SCREENSHOT_WIDTH = 1280; const RECORDING_SOURCE_SIZE_EXPRESSION = "({ width: Math.round(globalThis.innerWidth), height: Math.round(globalThis.innerHeight) })"; const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; 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 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", }; export 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)), }; }; const captureAnnotationScreenshot = ( tabId: string, wc: Electron.WebContents, cropRect: PreviewAnnotationRect | null, ): Effect.Effect<PreviewAnnotationPayload["screenshot"], PreviewManagerError> => Effect.tryPromise({ try: () => 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) => { 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 }, }; }), ); 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 lastPictureInPictureFrame: Buffer | null; } interface PictureInPictureSession { readonly window: BrowserWindow; readonly webContentsId: number; readonly ready: Deferred.Deferred<void, PreviewManagerError>; readonly initializationScope: Scope.Closeable; } interface PickSession { readonly cancel: Effect.Effect<void>; } interface BrowserControlSession { readonly webContentsId: number; 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; } const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ key: string; meta: boolean; shift: boolean; control: boolean; }> = Object.freeze([ // mod+shift+J → preview.toggle { key: "j", meta: true, shift: true, control: false }, // mod+K → command palette { key: "k", meta: true, shift: false, control: false }, // mod+, → settings (macOS convention) { key: ",", meta: true, shift: false, control: false }, // mod+W → close tab/panel { key: "w", meta: true, shift: false, control: false }, ]); /** * 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. */ const POPUP_PROTOCOLS = new Set(["http:", "https:"]); const isPopupUrl = (rawUrl: string): boolean => { try { return POPUP_PROTOCOLS.has(new URL(rawUrl).protocol); } catch { return false; } }; /** * Preferences for a popup a preview page opens. * * A popup is not a webview attach, so the `will-attach-webview` hardening in * `DesktopWindow` never sees it, and an unoverridden child would inherit the * guest's relaxed posture: the picker preload needs `contextIsolation: false` * to share `globalThis` with the previewed page, and no OAuth provider should * get that. The window keeps the opener and the guest session either way. */ const POPUP_WINDOW_OPTIONS = { webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, }, } satisfies Electron.BrowserWindowConstructorOptions; /** * Decides what a preview page's `window.open` should do. * * `"popup"` opens a real window, which scripted popups need: denying them makes * `window.open()` return `null` (OAuth SDKs report that as a blocked popup), and * navigating the preview tab instead destroys the opener the popup has to * `postMessage` its result back to. * * `target="_blank"` links arrive as a tab disposition and keep loading in the * preview tab, which is what people expect from a link inside a preview. */ export const previewWindowOpenAction = (details: { readonly url: string; readonly disposition: Electron.HandlerDetails["disposition"]; }): "popup" | "navigate" => details.disposition === "new-window" && isPopupUrl(details.url) ? "popup" : "navigate";