1/**
2 * Desktop side of the in-app browser preview.
3 *
4 * Hosts per-tab Chromium WebContents references (the actual <webview>
5 * elements live in the renderer; we only attach listeners and forward state
6 * here). Single layer-scoped browser session partition.
7 */
8import {
9 DesktopPreviewRecordingInputSchema,
10 DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER,
11} from "@t3tools/contracts";
12import type {
13 DesktopPreviewAnnotationTheme,
14 DesktopPreviewColorScheme,
15 DesktopPreviewFavicon,
16 DesktopPreviewOpenLinkEvent,
17 DesktopPreviewPointerEvent,
18 PreviewAnnotationPayload,
19 PreviewAnnotationRect,
20 PreviewAnnotationSubmissionResult,
21 DesktopPreviewRecordingArtifact,
22 DesktopPreviewRecordingFrame,
23 DesktopPreviewRecordingInputEvent,
24 DesktopPreviewScreenshotArtifact,
25 DesktopPreviewTabDefaults,
26 PreviewForwardedShortcut,
27} from "@t3tools/contracts";
28import * as HostProcess from "@t3tools/shared/HostProcess";
29import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings";
30import { isLocalLoopbackHost, normalizeHostname } from "@t3tools/shared/hostClassification";
31import { normalizePreviewUrl } from "@t3tools/shared/preview";
32import {
33 BrowserWindow,
34 ClipboardItem,
35 app,
36 type Session,
37 clipboard,
38 nativeImage,
39 shell,
40 webContents,
41} from "electron";
42import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity";
43import * as Cause from "effect/Cause";
44import * as Clock from "effect/Clock";
45import * as Context from "effect/Context";
46import * as Crypto from "effect/Crypto";
47import * as DateTime from "effect/DateTime";
48import * as Deferred from "effect/Deferred";
49import * as Effect from "effect/Effect";
50import * as Exit from "effect/Exit";
51import * as FileSystem from "effect/FileSystem";
52import * as Fiber from "effect/Fiber";
53import * as Layer from "effect/Layer";
54import * as Option from "effect/Option";
55import * as Path from "effect/Path";
56import * as Queue from "effect/Queue";
57import * as Ref from "effect/Ref";
58import * as Schema from "effect/Schema";
59import * as Semaphore from "effect/Semaphore";
60import * as Stream from "effect/Stream";
61import * as Schedule from "effect/Schedule";
62import * as Scope from "effect/Scope";
63import * as SynchronizedRef from "effect/SynchronizedRef";
64
65import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
66import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
67import { MENU_ACTION_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";
68import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
69import * as PreviewPasskeys from "./Passkeys.ts";
70import * as BrowserSession from "./BrowserSession.ts";
71import {
72 ANNOTATION_CAPTURED_CHANNEL,
73 ANNOTATION_SEND_ENABLED_CHANNEL,
74 ANNOTATION_THEME_CHANNEL,
75 CANCEL_PICK_CHANNEL,
76 ELEMENT_PICKED_CHANNEL,
77 HUMAN_INPUT_CHANNEL,
78 MOUSE_NAVIGATE_CHANNEL,
79 RECORDING_CURSOR_CHANNEL,
80 RECORDING_INPUT_CHANNEL,
81 RECORDING_CONTROLLER_CHANNEL,
82 RECORDING_POINTER_CHANNEL,
83 START_PICK_CHANNEL,
84} from "./GuestProtocol.ts";
85import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
86import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts";
87import { DEFAULT_RECORDING_INPUT_OPTIONS, type RecordingInputOptions } from "./RecordingInput.ts";
88
89export type PreviewNavStatus =
90 | { kind: "Idle" }
91 | { kind: "Loading"; url: string; title: string }
92 | { kind: "Success"; url: string; title: string }
93 | {
94 kind: "LoadFailed";
95 url: string;
96 title: string;
97 code: number;
98 description: string;
99 };
100
101export interface PreviewTabState {
102 tabId: string;
103 webContentsId: number | null;
104 navStatus: PreviewNavStatus;
105 canGoBack: boolean;
106 canGoForward: boolean;
107 zoomFactor: number;
108 pictureInPicture: boolean;
109 colorScheme: DesktopPreviewColorScheme;
110 /** User intent to silence this tab. Re-applied to each guest that attaches. */
111 audioMuted: boolean;
112 /** Observed from Chromium. Stays true while a muted tab keeps playing. */
113 audible: boolean;
114 controller: "human" | "agent" | "none";
115 favicon?: DesktopPreviewFavicon;
116 /** Set for a tab of the desktop's own server, which drives it over the browser channel. */
117 serverTab?: { readonly threadId: string; readonly tabId: string };
118 updatedAt: string;
119}
120
121/** Discrete zoom levels mirroring Chrome's preset list. */
122const ZOOM_LEVELS: ReadonlyArray<number> = [
123 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,
124];
125
126const DEFAULT_ZOOM_FACTOR = 1.0;
127const ZOOM_EPSILON = 0.001;
128/**
129 * A `[role]` container's innerText is its whole subtree, which turned one
130 * snapshot's element list into 60 KB of repeated page text. Names are labels,
131 * not content, so cap them where they are read.
132 */
133/** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */
134const RECORDING_ARM_GRACE_MS = 10_000;
135const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12);
136const PICTURE_IN_PICTURE_JPEG_QUALITY = 80;
137/**
138 * Cold guests can reject capturePage with UnknownVizError or never settle it.
139 * Bound each attempt so snapshots release control even when Chromium stalls.
140 */
141const CAPTURE_PAGE_RETRY_ATTEMPTS = 3;
142const CAPTURE_PAGE_RETRY_DELAY_MS = 120;
143const CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS = 1_000;
144const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480;
145const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320;
146const PICTURE_IN_PICTURE_MIN_WIDTH = 240;
147const PICTURE_IN_PICTURE_MIN_HEIGHT = 160;
148const PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON = 0.002;
149const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80;
150const requestRecordingCaptureExpression = (tabId: string): string =>
151 `globalThis[${JSON.stringify(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER)}]?.(${JSON.stringify(tabId)}) === true`;
152const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = {
153 colorScheme: "light",
154 radius: "0.625rem",
155 background: "white",
156 foreground: "oklch(0.269 0 0)",
157 popover: "white",
158 popoverForeground: "oklch(0.269 0 0)",
159 primary: "oklch(0.488 0.217 264)",
160 primaryForeground: "white",
161 muted: "rgb(0 0 0 / 4%)",
162 mutedForeground: "oklch(0.556 0 0)",
163 accent: "rgb(0 0 0 / 4%)",
164 accentForeground: "oklch(0.269 0 0)",
165 border: "rgb(0 0 0 / 8%)",
166 input: "rgb(0 0 0 / 10%)",
167 ring: "oklch(0.488 0.217 264)",
168 fontSans: "system-ui, sans-serif",
169 fontMono: "ui-monospace, monospace",
170};
171
172const buildPreviewPictureInPictureDataUrl = (): string => {
173 const html = `<!doctype html>
174<html>
175 <head>
176 <meta charset="utf-8">
177 <meta
178 http-equiv="Content-Security-Policy"
179 content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'"
180 >
181 <meta name="color-scheme" content="dark">
182 <style>
183 html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #111; }
184 body { display: grid; place-items: center; }
185 img { width: 100%; height: 100%; object-fit: contain; user-select: none; -webkit-user-drag: none; }
186 </style>
187 </head>
188 <body>
189 <img id="preview-frame" alt="Live browser preview">
190 <script>
191 const frame = document.getElementById("preview-frame");
192 window.previewPictureInPicture.onFrame((next) => {
193 frame.src = "data:image/jpeg;base64," + next.data;
194 });
195 </script>
196 </body>
197</html>`;
198 return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
199};
200
201export const fitPictureInPictureContentSize = (
202 current: ReadonlyArray<number>,
203 aspectRatio: number,
204): readonly [width: number, height: number] => {
205 const currentWidth = Math.max(1, current[0] ?? PICTURE_IN_PICTURE_INITIAL_WIDTH);
206 const currentHeight = Math.max(1, current[1] ?? PICTURE_IN_PICTURE_INITIAL_HEIGHT);
207 const currentArea = currentWidth * currentHeight;
208 let width = Math.sqrt(currentArea * aspectRatio);
209 let height = width / aspectRatio;
210 const minimumScale = Math.max(
211 1,
212 PICTURE_IN_PICTURE_MIN_WIDTH / width,
213 PICTURE_IN_PICTURE_MIN_HEIGHT / height,
214 );
215 width *= minimumScale;
216 height *= minimumScale;
217 return [Math.round(width), Math.round(height)];
218};
219
220export const recordingFileExtension = (mimeType: string): string => {
221 const subtype = mimeType.split(";", 1)[0]?.trim().toLowerCase().split("/")[1] ?? "";
222 const extension = subtype.replace(/^x-/, "").replace(/[^a-z0-9]/g, "");
223 return extension || "video";
224};
225
226const artifactSiteSlug = (rawUrl: string): string => {
227 try {
228 const url = new URL(rawUrl);
229 const slug = url.hostname
230 .toLowerCase()
231 .replace(/[^a-z0-9]+/g, "-")
232 .replace(/^-+|-+$/g, "")
233 .slice(0, MAX_ARTIFACT_SITE_SLUG_LENGTH)
234 .replace(/-+$/g, "");
235 return slug || "site";
236 } catch {
237 return "site";
238 }
239};
240
241export const PreviewAutomationSelectorKind = Schema.Literals([
242 "focused-element",
243 "selector",
244 "locator",
245]);
246export type PreviewAutomationSelectorKind = typeof PreviewAutomationSelectorKind.Type;
247
248export const PreviewAutomationEvaluationDetailKind = Schema.Literals([
249 "exception-description",
250 "exception-text",
251 "unknown",
252]);
253export type PreviewAutomationEvaluationDetailKind =
254 typeof PreviewAutomationEvaluationDetailKind.Type;
255
256interface PreviewOperationContext {
257 readonly operation: string;
258 readonly tabId?: string;
259 readonly webContentsId?: number;
260 readonly artifactPath?: string;
261}
262
263const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {
264 if (typeof value !== "object" || value === null) return null;
265 const rect = value as Record<string, unknown>;
266 const x = rect["x"];
267 const y = rect["y"];
268 const width = rect["width"];
269 const height = rect["height"];
270 if (
271 typeof x !== "number" ||
272 !Number.isFinite(x) ||
273 typeof y !== "number" ||
274 !Number.isFinite(y) ||
275 typeof width !== "number" ||
276 !Number.isFinite(width) ||
277 typeof height !== "number" ||
278 !Number.isFinite(height) ||
279 width <= 0 ||
280 height <= 0
281 ) {
282 return null;
283 }
284 return {
285 x: Math.max(0, Math.floor(x)),
286 y: Math.max(0, Math.floor(y)),
287 width: Math.max(1, Math.ceil(width)),
288 height: Math.max(1, Math.ceil(height)),
289 };
290};
291
292/**
293 * Crops a full-page capture to a picked annotation. `cropRect` is in CSS px;
294 * the capture's pixels are CSS px × the guest's `devicePixelRatio`.
295 */
296const cropAnnotationScreenshot = (
297 image: Electron.NativeImage,
298 cropRect: PreviewAnnotationRect | null,
299 devicePixelRatio: number,
300): PreviewAnnotationPayload["screenshot"] => {
301 const full = image.getSize();
302 if (!cropRect) {
303 return {
304 dataUrl: image.toDataURL(),
305 width: full.width,
306 height: full.height,
307 cropRect: { x: 0, y: 0, width: full.width, height: full.height },
308 };
309 }
310 const scale = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1;
311 const x = Math.min(full.width - 1, Math.max(0, Math.floor(cropRect.x * scale)));
312 const y = Math.min(full.height - 1, Math.max(0, Math.floor(cropRect.y * scale)));
313 const cropped = image.crop({
314 x,
315 y,
316 width: Math.max(1, Math.min(full.width - x, Math.ceil(cropRect.width * scale))),
317 height: Math.max(1, Math.min(full.height - y, Math.ceil(cropRect.height * scale))),
318 });
319 const size = cropped.getSize();
320 return { dataUrl: cropped.toDataURL(), width: size.width, height: size.height, cropRect };
321};
322
323const findZoomStep = (current: number): number => {
324 const index = ZOOM_LEVELS.findIndex(
325 (level) => Math.abs(level - current) < ZOOM_EPSILON || level > current,
326 );
327 if (index < 0) return ZOOM_LEVELS.length - 1;
328 return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1;
329};
330
331/**
332 * Clamp a client-supplied zoom factor onto the discrete ladder. The setting is
333 * chosen from the same ladder, but it arrives over IPC from a schema that only
334 * guarantees a positive number, so an out-of-band value snaps to the nearest
335 * step rather than leaving the guest at a zoom the zoom controls can't reach.
336 */
337const normalizeZoomFactor = (value: number | undefined): number => {
338 if (value === undefined || !Number.isFinite(value)) return DEFAULT_ZOOM_FACTOR;
339 let closest = ZOOM_LEVELS[0]!;
340 for (const level of ZOOM_LEVELS) {
341 if (Math.abs(level - value) < Math.abs(closest - value)) closest = level;
342 }
343 return closest;
344};
345
346const nextZoomLevel = (current: number, direction: "in" | "out"): number => {
347 const step = findZoomStep(current);
348 if (direction === "in") {
349 return ZOOM_LEVELS[Math.min(step + 1, ZOOM_LEVELS.length - 1)] ?? current;
350 }
351 return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current;
352};
353
354type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect<void>;
355type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect<void>;
356
357type PreviewInputSignal =
358 | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number }
359 | { readonly kind: "key"; readonly key: string; readonly code: string };
360
361interface ManagedListeners {
362 readonly attachmentId: symbol;
363 readonly cancelFaviconCapture: () => void;
364 readonly scope: Scope.Closeable;
365 readonly webContents: Electron.WebContents;
366}
367
368type FrameCaptureConsumer = "picture-in-picture" | "recording";
369
370interface FrameCaptureSession {
371 readonly recordingInputOptions?: RecordingInputOptions;
372 readonly scope: Scope.Closeable | null;
373 readonly consumers: ReadonlySet<FrameCaptureConsumer>;
374 readonly unthrottledWebContentsIds: ReadonlySet<number>;
375 readonly lastPictureInPictureFrame: Buffer | null;
376}
377
378interface PictureInPictureSession {
379 readonly window: BrowserWindow;
380 readonly webContentsId: number;
381 readonly ready: Deferred.Deferred<void, PreviewManagerError>;
382 readonly initializationScope: Scope.Closeable;
383}
384
385/** The tab whose frame the next `getDisplayMedia()` request is allowed to capture. */
386interface PendingRecording {
387 readonly tabId: string;
388 readonly webContents: Electron.WebContents;
389 readonly requestingFrameTreeNodeId: number;
390 readonly armedAtMillis: number;
391}
392
393interface PickSession {
394 readonly cancel: Effect.Effect<void>;
395}
396
397interface BrowserControlSession {
398 readonly webContentsId: number;
399 // Pins the WebContents' Debugger wrapper for the session's lifetime.
400 // Electron's Debugger is GC-managed but registered with Chromium as a raw
401 // DevToolsAgentHostClient pointer; collecting it while attached crashes the
402 // browser process (electron/electron#53376). Detach must also go through
403 // this reference: `wc.debugger` throws once the WebContents is destroyed.
404 readonly debugger: Electron.Debugger;
405 readonly semaphore: Semaphore.Semaphore;
406 readonly scope: Scope.Closeable;
407 readonly onMessage: (
408 event: Electron.Event,
409 method: string,
410 params: Record<string, unknown>,
411 ) => void;
412}
413
414const isRecordingInput = Schema.is(DesktopPreviewRecordingInputSchema);
415
416type RecordingInputListener = (event: DesktopPreviewRecordingInputEvent) => Effect.Effect<void>;
417
418type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect<void>;
419
420type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect<void>;
421
422/**
423 * Protocols a preview page may open in a real popup window.
424 *
425 * `about:blank` stays out: Chromium skips browser-side navigation for it, so the
426 * child copies the guest's `contextIsolation: false` preferences and Electron
427 * gives no way to override them. Those popups keep loading in the preview tab.
428 *
429 * Deliberately not `ElectronShell.parseSafeExternalUrl`: that also admits
430 * `vscode://vscode-remote/...` deep links, which belong in `shell.openExternal`
431 * and not in a window spawned by a third-party page in the preview.
432 */
433const POPUP_PROTOCOLS = new Set(["http:", "https:"]);
434
435const isPopupUrl = (rawUrl: string): boolean => {
436 try {
437 return POPUP_PROTOCOLS.has(new URL(rawUrl).protocol);
438 } catch {
439 return false;
440 }
441};
442
443/**
444 * Preferences for a popup a preview page opens.
445 *
446 * A popup is not a webview attach, so the `will-attach-webview` hardening in
447 * `DesktopWindow` never sees it, and an unoverridden child would inherit the
448 * guest's relaxed posture: the picker preload needs `contextIsolation: false`
449 * to share `globalThis` with the previewed page, and no OAuth provider should
450 * get that. The window keeps the opener and the guest session either way.
451 */
452const POPUP_WINDOW_OPTIONS = {
453 webPreferences: {
454 contextIsolation: true,
455 nodeIntegration: false,
456 sandbox: true,
457 },
458} satisfies Electron.BrowserWindowConstructorOptions;
459
460/**
461 * Decides what a preview page's `window.open` should do.
462 *
463 * `"popup"` opens a real window, which scripted popups need: denying them makes
464 * `window.open()` return `null` (OAuth SDKs report that as a blocked popup), and
465 * navigating the preview tab instead destroys the opener the popup has to
466 * `postMessage` its result back to.
467 *
468 * `"new-tab"` hands a `target="_blank"` link (a tab disposition) to the web
469 * app, which opens it as another preview tab so the page that held the link
470 * stays put. Schemes a popup cannot be hardened for keep loading in place, and
471 * so does a form POST with a body, which a new tab could only reopen as a GET.
472 *
473 * `"deny"` is for a blank window (`""` or `about:blank`). It cannot get a real
474 * window (see `POPUP_PROTOCOLS`), and loading it into the preview tab would
475 * replace the opener with an empty page. Denying makes `window.open()` return
476 * `null`, which SDKs such as MSAL treat as a blocked popup and fall back from.
477 */
478export const previewWindowOpenAction = (details: {
479 readonly url: string;
480 readonly disposition: Electron.HandlerDetails["disposition"];
481 readonly postBody?: Electron.PostBody;
482}): "popup" | "new-tab" | "navigate" | "deny" => {
483 if (details.url === "" || details.url === "about:blank") return "deny";
484 if (!isPopupUrl(details.url)) return "navigate";
485 if (details.disposition === "new-window") return "popup";
486 return !details.postBody &&
487 (details.disposition === "foreground-tab" || details.disposition === "background-tab")
488 ? "new-tab"
489 : "navigate";
490};
491
492export const isPreviewRefreshShortcut = (input: Electron.Input): boolean =>
493 input.type === "keyDown" &&
494 input.key.toLowerCase() === "r" &&
495 (input.meta || input.control) &&
496 !input.shift &&
497 !input.alt;
498
499export const isPreviewEditingShortcut = (
500 input: Electron.Input,
501 platform: NodeJS.Platform,
502): boolean => {
503 const isMac = platform === "darwin";
504 if (isMac ? !input.meta || input.control : !input.control || input.meta) return false;
505
506 const key = input.key.toLowerCase();
507 // Option changes the DOM key for macOS Paste and Match Style (for example, to ◊).
508 if (isMac && input.alt && input.shift && input.code === "KeyV") return true;
509 if (key === "v" && input.shift) return input.alt === isMac;
510 if (input.alt) return false;
511 if (key === "z") return !input.shift || platform !== "win32";
512 if (input.shift) return false;
513 return (
514 key === "a" ||
515 key === "c" ||
516 key === "v" ||
517 key === "x" ||
518 (key === "y" && platform === "win32")
519 );
520};
521
522const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => {
523 if (typeof value !== "object" || value === null || !("kind" in value)) return false;
524 if (value.kind === "pointer") {
525 return (
526 "x" in value &&
527 typeof value.x === "number" &&
528 "y" in value &&
529 typeof value.y === "number" &&
530 "button" in value &&
531 typeof value.button === "number"
532 );
533 }
534 return (
535 value.kind === "key" &&
536 "key" in value &&
537 typeof value.key === "string" &&
538 "code" in value &&
539 typeof value.code === "string"
540 );
541};
542
543const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* (
544 artifactDirectory: string,
545 pictureInPicturePreloadPath: string,
546) {
547 const fileSystem = yield* FileSystem.FileSystem;
548 const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory;
549 const hostPlatform = yield* HostProcess.Platform;
550 const hostArchitecture = yield* HostProcess.Architecture;
551 const path = yield* Path.Path;
552 const crypto = yield* Crypto.Crypto;
553 const parentScope = yield* Scope.Scope;
554 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
555 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
556 const context = yield* Effect.context<never>();
557 const runFork = Effect.runForkWith(context);
558 const resolvedArtifactDirectory = path.resolve(artifactDirectory);
559
560 let forwardedShortcuts: ReadonlyArray<PreviewForwardedShortcut> = [];
561 const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);
562 const annotationSendEnabled = new Map<string, boolean>();
563 const mainWindowRef = yield* Ref.make<Option.Option<BrowserWindow>>(Option.none());
564 const tabsRef = yield* SynchronizedRef.make<ReadonlyMap<string, PreviewTabState>>(new Map());
565 const attachedRef = yield* Ref.make<ReadonlyMap<number, ManagedListeners>>(new Map());
566 const listenersRef = yield* Ref.make<ReadonlySet<Listener>>(new Set());
567 const pointerEventListenersRef = yield* Ref.make<ReadonlySet<PointerEventListener>>(new Set());
568 const openLinkListenersRef = yield* Ref.make<ReadonlySet<OpenLinkListener>>(new Set());
569 const recordingInputListenersRef = yield* Ref.make<ReadonlySet<RecordingInputListener>>(
570 new Set(),
571 );
572 const recordingFrameListenersRef = yield* Ref.make<ReadonlySet<RecordingFrameListener>>(
573 new Set(),
574 );
575 const pickSessionsRef = yield* Ref.make<ReadonlyMap<string, PickSession>>(new Map());
576 const controlSessionsRef = yield* SynchronizedRef.make<
577 ReadonlyMap<number, BrowserControlSession>
578 >(new Map());
579 const frameCaptureSessionsRef = yield* SynchronizedRef.make<
580 ReadonlyMap<string, FrameCaptureSession>
581 >(new Map());
582 const pictureInPictureSessionsRef = yield* SynchronizedRef.make<
583 ReadonlyMap<string, PictureInPictureSession>
584 >(new Map());
585 const pictureInPictureAspectRatiosRef = yield* Ref.make<ReadonlyMap<string, number>>(new Map());
586 const pictureInPictureMutationSemaphore = yield* Semaphore.make(1);
587 const closingTabIdsRef = yield* Ref.make<ReadonlySet<string>>(new Set());
588 // Tab recording uses `setDisplayMediaRequestHandler` because Electron's legacy
589 // `getMediaSourceId` + `chromeMediaSource: "tab"` capture path was removed upstream
590 // (electron#44618) and now always rejects with NotAllowedError.
591 let pendingRecording: PendingRecording | null = null;
592 const displayMediaHandlerSessions = new WeakSet<Session>();
593 let frameCaptureWindowOpen = true;
594 let currentMainWindow: BrowserWindow | undefined;
595 let mainWindowCleanupFiber: Fiber.Fiber<void, never> | undefined;
596 const tabLifecycleLocks = new Map<
597 string,
598 { readonly semaphore: Semaphore.Semaphore; users: number }
599 >();
600 const tabLifecycleGenerations = new Map<string, number>();
601
602 const attempt = <A>(errorContext: PreviewOperationContext, evaluate: () => A) =>
603 Effect.try({
604 try: evaluate,
605 catch: (cause) => new PreviewOperationError({ ...errorContext, cause }),
606 });
607 const attemptPromise = <A>(
608 errorContext: PreviewOperationContext,
609 evaluate: () => PromiseLike<A>,
610 ) =>
611 Effect.tryPromise({
612 try: evaluate,
613 catch: (cause) => new PreviewOperationError({ ...errorContext, cause }),
614 });
615 const capturePageWithRetry = Effect.fn("PreviewManager.capturePageWithRetry")(function* (
616 errorContext: PreviewOperationContext,
617 tabId: string,
618 wc: Electron.WebContents,
619 ) {
620 const requireCurrentGuest = Effect.gen(function* () {
621 const tabs = yield* SynchronizedRef.get(tabsRef);
622 if (wc.isDestroyed() || tabs.get(tabId)?.webContentsId !== wc.id) {
623 return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId: wc.id });
624 }
625 });
626 const capture = Effect.gen(function* () {
627 // Check after the retry delay, and again before accepting its result.
628 yield* requireCurrentGuest;
629 const image = yield* Effect.tryPromise({
630 // An abort-signal parameter makes a stalled promise interruptible.
631 try: (_signal) => wc.capturePage(),
632 catch: (cause) => new PreviewOperationError({ ...errorContext, cause }),
633 }).pipe(
634 Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS),
635 Effect.catchTags({
636 TimeoutError: (cause) =>
637 Effect.fail(new PreviewOperationError({ ...errorContext, cause })),
638 }),
639 );
640 yield* requireCurrentGuest;
641 return image;
642 });
643 return yield* capture.pipe(
644 Effect.retry({
645 times: CAPTURE_PAGE_RETRY_ATTEMPTS - 1,
646 schedule: Schedule.spaced(CAPTURE_PAGE_RETRY_DELAY_MS),
647 while: isPreviewOperationError,
648 }),
649 );
650 });
651 const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso));
652 const currentMillis = Clock.currentTimeMillis;
653 const replaceMap = <K, V>(
654 source: ReadonlyMap<K, V>,
655 update: (copy: Map<K, V>) => void,
656 ): ReadonlyMap<K, V> => {
657 const copy = new Map(source);
658 update(copy);
659 return copy;
660 };
661 const withTabLifecycleLock = <A, E, R>(
662 tabId: string,
663 effect: Effect.Effect<A, E, R>,
664 ): Effect.Effect<A, E, R> =>
665 Effect.suspend(() => {
666 const lifecycle = tabLifecycleLocks.get(tabId) ?? {
667 semaphore: Semaphore.makeUnsafe(1),
668 users: 0,
669 };
670 lifecycle.users += 1;
671 tabLifecycleLocks.set(tabId, lifecycle);
672 return lifecycle.semaphore.withPermit(effect).pipe(
673 Effect.ensuring(
674 Effect.sync(() => {
675 lifecycle.users -= 1;
676 if (lifecycle.users === 0 && tabLifecycleLocks.get(tabId) === lifecycle) {
677 tabLifecycleLocks.delete(tabId);
678 }
679 }),
680 ),
681 );
682 });
683 const setWindowBackgroundThrottling = Effect.fnUntraced(function* (
684 window: BrowserWindow,
685 enabled: boolean,
686 ) {
687 if (window.isDestroyed()) return;
688 yield* attempt({ operation: "frameCapture.setBackgroundThrottling" }, () =>
689 window.webContents.setBackgroundThrottling(enabled),
690 );
691 });
692 const setFrameCaptureBackgroundThrottling = Effect.fnUntraced(function* (enabled: boolean) {
693 const mainWindow = yield* Ref.get(mainWindowRef);
694 if (Option.isNone(mainWindow)) return;
695 yield* setWindowBackgroundThrottling(mainWindow.value, enabled);
696 });
697 const setFrameCaptureWebContentsBackgroundThrottling = Effect.fnUntraced(function* (
698 wc: Electron.WebContents,
699 enabled: boolean,
700 ) {
701 if (wc.isDestroyed()) return;
702 yield* attempt(
703 {
704 operation: "frameCapture.setBackgroundThrottling",
705 webContentsId: wc.id,
706 },
707 () => wc.setBackgroundThrottling(enabled),
708 );
709 });
710 const restoreFrameCaptureWebContentsBackgroundThrottling = Effect.fnUntraced(function* (
711 webContentsIds: ReadonlySet<number>,
712 ) {
713 yield* Effect.forEach(
714 webContentsIds,
715 (webContentsId) => {
716 const wc = webContents.fromId(webContentsId);
717 if (!wc || wc.isDestroyed()) return Effect.void;
718 return setFrameCaptureWebContentsBackgroundThrottling(wc, true).pipe(
719 Effect.retry({ times: 2 }),
720 Effect.catch((error) =>
721 Effect.logWarning("Failed to restore preview webview frame capture throttling.", {
722 webContentsId,
723 error,
724 }),
725 ),
726 );
727 },
728 { concurrency: "unbounded", discard: true },
729 );
730 });
731 const keepFrameCaptureWebContentsUnthrottled = Effect.fnUntraced(function* (
732 tabId: string,
733 wc: Electron.WebContents,
734 ) {
735 yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => {
736 const current = sessions.get(tabId);
737 if (!current || current.unthrottledWebContentsIds.has(wc.id)) {
738 return Effect.succeed([undefined, sessions] as const);
739 }
740 return setFrameCaptureWebContentsBackgroundThrottling(wc, false).pipe(
741 Effect.tap(() =>
742 Effect.gen(function* () {
743 if (!current.consumers.has("recording")) return;
744 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
745 yield* attempt({ operation: "recording.cursor", tabId, webContentsId: wc.id }, () =>
746 wc.send(
747 RECORDING_CURSOR_CHANNEL,
748 true,
749 current.recordingInputOptions,
750 tab?.controller,
751 ),
752 );
753 }),
754 ),
755 Effect.map(
756 () =>
757 [
758 undefined,
759 replaceMap(sessions, (copy) => {
760 copy.set(tabId, {
761 ...current,
762 unthrottledWebContentsIds: new Set([...current.unthrottledWebContentsIds, wc.id]),
763 });
764 }),
765 ] as const,
766 ),
767 );
768 });
769 });
770 const stopFrameCapture = Effect.fn("PreviewManager.stopFrameCapture")(function* (
771 tabId: string,
772 consumer: FrameCaptureConsumer,
773 ) {
774 yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) =>
775 Effect.gen(function* () {
776 const current = sessions.get(tabId);
777 if (!current || !current.consumers.has(consumer)) {
778 return [undefined, sessions] as const;
779 }
780 if (consumer === "recording") {
781 yield* Effect.forEach(current.unthrottledWebContentsIds, (id) =>
782 attempt({ operation: "recording.cursor", tabId, webContentsId: id }, () => {
783 const contents = webContents.fromId(id);
784 if (contents && !contents.isDestroyed())
785 contents.send(RECORDING_CURSOR_CHANNEL, false);
786 }).pipe(Effect.ignore),
787 );
788 }
789 const consumers = new Set(current.consumers);
790 consumers.delete(consumer);
791 if (consumers.size > 0) {
792 return [
793 consumer === "picture-in-picture" ? current.scope : undefined,
794 replaceMap(sessions, (copy) => {
795 copy.set(tabId, {
796 ...current,
797 scope: consumer === "picture-in-picture" ? null : current.scope,
798 consumers,
799 lastPictureInPictureFrame:
800 consumer === "picture-in-picture" ? null : current.lastPictureInPictureFrame,
801 });
802 }),
803 ] as const;
804 }
805 const remainingSessions = replaceMap(sessions, (copy) => {
806 copy.delete(tabId);
807 });
808 yield* restoreFrameCaptureWebContentsBackgroundThrottling(
809 current.unthrottledWebContentsIds,
810 );
811 if (remainingSessions.size === 0) {
812 yield* setFrameCaptureBackgroundThrottling(true).pipe(
813 Effect.retry({ times: 2 }),
814 Effect.catch((error) =>
815 Effect.logWarning("Failed to restore preview frame capture throttling.", { error }),
816 ),
817 );
818 }
819 return [current.scope, remainingSessions] as const;
820 }),
821 ).pipe(
822 Effect.flatMap((captureScope) =>
823 captureScope ? Scope.close(captureScope, Exit.void).pipe(Effect.ignore) : Effect.void,
824 ),
825 Effect.uninterruptible,
826 );
827 });
828
829 const stopAllRecordings = Effect.fn("PreviewManager.stopAllRecordings")(function* () {
830 pendingRecording = null;
831 const sessions = yield* SynchronizedRef.get(frameCaptureSessionsRef);
832 yield* Effect.forEach(sessions.keys(), (tabId) => stopFrameCapture(tabId, "recording"), {
833 concurrency: "unbounded",
834 discard: true,
835 });
836 });
837
838 const deliverEvent = (
839 eventKind:
840 | "state-change"
841 | "recording-frame"
842 | "recording-input"
843 | "pointer-event"
844 | "open-link",
845 tabId: string,
846 delivery: () => Effect.Effect<void>,
847 ) =>
848 Effect.suspend(delivery).pipe(
849 Effect.catchCauseIf(
850 (cause) => !Cause.hasInterrupts(cause),
851 (cause) =>
852 Effect.logWarning("Desktop preview event listener failed.", {
853 eventKind,
854 tabId,
855 cause,
856 }),
857 ),
858 );
859
860 const emit = Effect.fn("PreviewManager.emit")(function* (tabId: string, state: PreviewTabState) {
861 const listeners = yield* Ref.get(listenersRef);
862 yield* Effect.forEach(
863 listeners,
864 (listener) => deliverEvent("state-change", tabId, () => listener(tabId, state)),
865 { discard: true },
866 );
867 });
868
869 const emitIfCurrent = Effect.fn("PreviewManager.emitIfCurrent")(function* (
870 tabId: string,
871 state: PreviewTabState,
872 ) {
873 if ((yield* SynchronizedRef.get(tabsRef)).get(tabId) === state) {
874 yield* emit(tabId, state);
875 }
876 });
877
878 const update = Effect.fn("PreviewManager.update")(function* (
879 tabId: string,
880 patch: Partial<PreviewTabState>,
881 humanPoint?: { readonly x: number; readonly y: number },
882 ) {
883 const updatedAt = yield* currentIso;
884 const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
885 const current = tabs.get(tabId);
886 if (!current) return [Option.none<PreviewTabState>(), tabs] as const;
887 const state: PreviewTabState = { ...current, ...patch, updatedAt };
888 return [
889 Option.some(state),
890 replaceMap(tabs, (copy) => {
891 copy.set(tabId, state);
892 }),
893 ] as const;
894 });
895 // emitIfCurrent, not emit: an event-driven writer such as syncTabAudible
896 // can commit between the modify above and here, and republishing this
897 // snapshot would roll the UI back to a value that writer will not send
898 // again because it suppresses unchanged audibility.
899 if (Option.isSome(next)) {
900 if (patch.controller !== undefined && next.value.webContentsId != null) {
901 const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId);
902 const webContentsId = next.value.webContentsId;
903 if (capture?.consumers.has("recording")) {
904 yield* attempt({ operation: "recording.controller", tabId }, () => {
905 const contents = webContents.fromId(webContentsId);
906 if (contents && !contents.isDestroyed())
907 contents.send(RECORDING_CONTROLLER_CHANNEL, patch.controller, humanPoint);
908 }).pipe(Effect.ignore);
909 }
910 }
911 yield* emitIfCurrent(tabId, next.value);
912 }
913 });
914
915 /**
916 * Pushes a tab's zoom factor onto whichever guest it currently owns, reading
917 * both at call time. Anything that applies zoom after an await goes through
918 * here: a snapshot taken before the await can be older than a zoom action that
919 * landed in between, and re-applying it would roll that action back.
920 */
921 const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) {
922 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
923 if (!tab || tab.webContentsId == null) return;
924 const wc = webContents.fromId(tab.webContentsId);
925 if (!wc || wc.isDestroyed()) return;
926 yield* attempt({ operation: "assertTabZoom", tabId, webContentsId: wc.id }, () =>
927 wc.setZoomFactor(tab.zoomFactor),
928 ).pipe(Effect.ignore);
929 });
930
931 /**
932 * Mute counterpart to {@link assertTabZoom}: pushes the tab's committed mute
933 * onto whichever guest it currently owns, reading both at call time so an
934 * older snapshot can never roll back a mute action that landed after it.
935 *
936 * Failures propagate so the user-facing setter can roll its commit back.
937 * Reconciliation callers, where a guest going away mid-attach is expected,
938 * discard the error at their own call site.
939 */
940 const assertTabAudioMuted = Effect.fn("PreviewManager.assertTabAudioMuted")(function* (
941 tabId: string,
942 ) {
943 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
944 if (!tab || tab.webContentsId == null) return;
945 const wc = webContents.fromId(tab.webContentsId);
946 if (!wc || wc.isDestroyed()) return;
947 yield* attempt({ operation: "assertTabAudioMuted", tabId, webContentsId: wc.id }, () =>
948 wc.setAudioMuted(tab.audioMuted),
949 );
950 });
951
952 /**
953 * Publishes an observed audibility value for the guest that reported it.
954 * Shared by the `audio-state-changed` handler and the post-attach reconcile
955 * so both drop values from a guest the tab no longer owns, and both skip
956 * unchanged values: Chromium re-emits per media element, and republishing
957 * would cost an IPC push per element rather than per real transition.
958 */
959 const syncTabAudible = Effect.fn("PreviewManager.syncTabAudible")(function* (
960 tabId: string,
961 wc: Electron.WebContents,
962 audible: boolean,
963 ) {
964 if (wc.isDestroyed()) return;
965 const updatedAt = yield* currentIso;
966 const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
967 const current = tabs.get(tabId);
968 if (
969 !current ||
970 current.webContentsId !== wc.id ||
971 webContents.fromId(wc.id) !== wc ||
972 current.audible === audible
973 ) {
974 return [Option.none<PreviewTabState>(), tabs] as const;
975 }
976 const state: PreviewTabState = { ...current, audible, updatedAt };
977 return [
978 Option.some(state),
979 replaceMap(tabs, (copy) => {
980 copy.set(tabId, state);
981 }),
982 ] as const;
983 });
984 if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value);
985 });
986
987 const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* (
988 tabId: string,
989 ) {
990 const tabs = yield* SynchronizedRef.get(tabsRef);
991 const tab = tabs.get(tabId);
992 if (!tab) {
993 return yield* new PreviewTabNotFoundError({ tabId });
994 }
995 if (tab.webContentsId == null) {
996 return yield* new PreviewWebviewNotInitializedError({ tabId });
997 }
998 const wc = webContents.fromId(tab.webContentsId);
999 if (!wc) {
1000 return yield* new PreviewWebContentsNotFoundError({
1001 tabId,
1002 webContentsId: tab.webContentsId,
1003 });
1004 }
1005 return wc;
1006 });
1007
1008 const resolveArtifactPath = (artifactPath: string) =>
1009 attempt({ operation: "resolveArtifactPath", artifactPath }, () => {
1010 const resolvedPath = path.resolve(artifactPath);
1011 const relativePath = path.relative(resolvedArtifactDirectory, resolvedPath);
1012 if (
1013 relativePath.length === 0 ||
1014 relativePath === ".." ||
1015 relativePath.startsWith(`..${path.sep}`) ||
1016 path.isAbsolute(relativePath)
1017 ) {
1018 return null;
1019 }
1020 return resolvedPath;
1021 }).pipe(
1022 Effect.filterOrFail(
1023 (resolvedPath) => resolvedPath !== null,
1024 () =>
1025 new PreviewArtifactPathOutsideDirectoryError({
1026 artifactPath,
1027 artifactDirectory: resolvedArtifactDirectory,
1028 }),
1029 ),
1030 );
1031
1032 const tabIdForWebContents = Effect.fnUntraced(function* (webContentsId: number) {
1033 const tabs = yield* SynchronizedRef.get(tabsRef);
1034 return (
1035 Array.from(tabs.entries()).find(([, tab]) => tab.webContentsId === webContentsId)?.[0] ?? null
1036 );
1037 });
1038
1039 const detachControlSession = Effect.fn("PreviewManager.detachControlSession")(function* (
1040 webContentsId: number,
1041 /** The server tab it rendered, when the caller already took the tab out of `tabsRef`. */
1042 closedServerTab?: PreviewTabState["serverTab"],
1043 ) {
1044 const control = yield* SynchronizedRef.modify(controlSessionsRef, (sessions) => [
1045 sessions.get(webContentsId),
1046 replaceMap(sessions, (copy) => {
1047 copy.delete(webContentsId);
1048 }),
1049 ]);
1050 if (control) {
1051 // The server can only drive a tab while the desktop holds its debugger.
1052 if (closedServerTab) browserHost.detach(closedServerTab);
1053 for (const tab of (yield* SynchronizedRef.get(tabsRef)).values()) {
1054 if (tab.webContentsId === webContentsId && tab.serverTab) browserHost.detach(tab.serverTab);
1055 }
1056 yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore);
1057 }
1058 });
1059
1060 const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* (
1061 wc: Electron.WebContents,
1062 ) {
1063 return yield* SynchronizedRef.modifyEffect(
1064 controlSessionsRef,
1065 (
1066 sessions,
1067 ): Effect.Effect<
1068 readonly [BrowserControlSession, ReadonlyMap<number, BrowserControlSession>],
1069 PreviewManagerError
1070 > => {
1071 const existing = sessions.get(wc.id);
1072 if (existing) return Effect.succeed([existing, sessions] as const);
1073 // A guest can be destroyed while it waits for this lock, and its native
1074 // methods throw once it is.
1075 if (wc.isDestroyed()) {
1076 return Effect.fail(
1077 new PreviewOperationError({
1078 operation: "ensureControlSession",
1079 webContentsId: wc.id,
1080 cause: new Error("WebContents was destroyed"),
1081 }),
1082 );
1083 }
1084 if (wc.isDevToolsOpened()) {
1085 return Effect.fail(
1086 new PreviewAutomationDevToolsOpenError({
1087 webContentsId: wc.id,
1088 }),
1089 );
1090 }
1091 if (wc.debugger.isAttached()) {
1092 return Effect.fail(
1093 new PreviewAutomationDebuggerAttachedError({
1094 webContentsId: wc.id,
1095 }),
1096 );
1097 }
1098 const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () {
1099 const semaphore = yield* Semaphore.make(1);
1100 const scope = yield* Scope.fork(parentScope, "sequential");
1101 const wcDebugger = wc.debugger;
1102 const consoleReleases = yield* Queue.sliding<void>(1);
1103 // Console message eviction does not release the debugger's strong
1104 // object handles. We keep text only, so release the whole group,
1105 // including its object-id bookkeeping. Coalesce bursts behind one
1106 // command rather than queueing a command for every logged object.
1107 yield* Effect.forkIn(
1108 Effect.forever(
1109 Queue.take(consoleReleases).pipe(
1110 Effect.andThen(
1111 attemptPromise({ operation: "releaseConsoleObjects", webContentsId: wc.id }, () =>
1112 wcDebugger.sendCommand("Runtime.releaseObjectGroup", {
1113 objectGroup: "console",
1114 }),
1115 ).pipe(Effect.ignore),
1116 ),
1117 ),
1118 ),
1119 scope,
1120 );
1121 const handleDebuggerMessage = Effect.fnUntraced(function* (
1122 method: string,
1123 params: Record<string, unknown>,
1124 ) {
1125 if (method === "Page.screencastFrame") {
1126 const sessionId = params["sessionId"];
1127 if (typeof sessionId === "number") {
1128 yield* attemptPromise(
1129 {
1130 operation: "ackScreencastFrame",
1131 webContentsId: wc.id,
1132 },
1133 () => wcDebugger.sendCommand("Page.screencastFrameAck", { sessionId }),
1134 ).pipe(Effect.ignore);
1135 }
1136 const tabId = yield* tabIdForWebContents(wc.id);
1137 const metadata =
1138 typeof params["metadata"] === "object" && params["metadata"] !== null
1139 ? (params["metadata"] as Record<string, unknown>)
1140 : {};
1141 if (tabId && typeof params["data"] === "string") {
1142 const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(
1143 tabId,
1144 );
1145 if (captureSession?.consumers.has("recording")) {
1146 const receivedAt = yield* currentIso;
1147 const listeners = yield* Ref.get(recordingFrameListenersRef);
1148 const frame: DesktopPreviewRecordingFrame = {
1149 tabId,
1150 data: params["data"],
1151 width:
1152 typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0,
1153 height:
1154 typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0,
1155 receivedAt,
1156 };
1157 yield* Effect.forEach(
1158 listeners,
1159 (listener) =>
1160 deliverEvent("recording-frame", frame.tabId, () => listener(frame)),
1161 { discard: true },
1162 );
1163 }
1164 }
1165 }
1166 if (method === "Runtime.consoleAPICalled" || method === "Runtime.exceptionThrown") {
1167 yield* Queue.offer(consoleReleases, undefined);
1168 }
1169 });
1170 const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => {
1171 runFork(handleDebuggerMessage(method, params));
1172 };
1173 yield* Scope.addFinalizer(
1174 scope,
1175 attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => {
1176 wcDebugger.off("message", onMessage);
1177 if (wcDebugger.isAttached()) wcDebugger.detach();
1178 }).pipe(Effect.ignore),
1179 );
1180 const control: BrowserControlSession = {
1181 webContentsId: wc.id,
1182 debugger: wcDebugger,
1183 semaphore,
1184 scope,
1185 onMessage,
1186 };
1187 const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () {
1188 yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => {
1189 wcDebugger.on("message", onMessage);
1190 wcDebugger.attach("1.3");
1191 });
1192 if (wc.getType() === "webview") {
1193 // Webview guests need an opaque base for Chromium's dark document canvas.
1194 yield* attemptPromise(
1195 { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id },
1196 () =>
1197 wcDebugger.sendCommand("Emulation.setDefaultBackgroundColorOverride", {
1198 color: { r: 255, g: 255, b: 255, a: 1 },
1199 }),
1200 );
1201 }
1202 const identity = browserChromeIdentity(wc.getUserAgent(), {
1203 platform: hostPlatform,
1204 arch: hostArchitecture,
1205 chromeVersion: process.versions.chrome,
1206 });
1207 if (identity) {
1208 yield* attemptPromise(
1209 { operation: "initializeDebugger.userAgent", webContentsId: wc.id },
1210 () => wcDebugger.sendCommand("Emulation.setUserAgentOverride", identity),
1211 );
1212 }
1213 return [
1214 control,
1215 replaceMap(sessions, (copy) => {
1216 copy.set(wc.id, control);
1217 }),
1218 ] as const;
1219 });
1220 return yield* initialize().pipe(
1221 Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)),
1222 );
1223 });
1224 return createControlSession();
1225 },
1226 );
1227 });
1228
1229 const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* (
1230 tabId: string,
1231 ) {
1232 const session = (yield* Ref.get(pickSessionsRef)).get(tabId);
1233 if (session) yield* session.cancel;
1234 });
1235
1236 const detachListeners = Effect.fn("PreviewManager.detachListeners")(function* (
1237 webContentsId: number,
1238 ) {
1239 const managed = yield* Ref.modify(attachedRef, (attached) => [
1240 attached.get(webContentsId),
1241 replaceMap(attached, (copy) => {
1242 copy.delete(webContentsId);
1243 }),
1244 ]);
1245 if (managed) {
1246 managed.cancelFaviconCapture();
1247 yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore);
1248 }
1249 });
1250
1251 const computeNavStatus = (wc: Electron.WebContents): PreviewNavStatus => {
1252 const url = wc.getURL();
1253 const title = wc.getTitle();
1254 if (url === "" || url === "about:blank") return { kind: "Idle" };
1255 // Main frame only. `isLoading()` covers the whole frame tree, so a
1256 // cross-origin iframe that loads after the page can leave it true with no
1257 // later event to clear it, and the tab's loading bar never finishes.
1258 if (wc.isLoadingMainFrame()) return { kind: "Loading", url, title };
1259 return { kind: "Success", url, title };
1260 };
1261
1262 const attachListeners = Effect.fn("PreviewManager.attachListeners")(function* (
1263 tabId: string,
1264 wc: Electron.WebContents,
1265 ) {
1266 const scope = yield* Scope.fork(parentScope, "sequential");
1267 const attachmentId = Symbol();
1268 let detachPasskeys = () => {};
1269 let documentId = 0;
1270 let nextRequestId = 0;
1271 let activeCapture: {
1272 readonly controller: AbortController;
1273 readonly documentId: number;
1274 readonly eventKey: string;
1275 readonly requestId: number;
1276 } | null = null;
1277 const cancelFaviconCapture = () => {
1278 documentId += 1;
1279 activeCapture?.controller.abort();
1280 activeCapture = null;
1281 };
1282 const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* (
1283 preserveLoadFailure: boolean,
1284 confirmedNavigation = false,
1285 ) {
1286 if (wc.isDestroyed()) return;
1287 const computedNavStatus = computeNavStatus(wc);
1288 const canGoBack = wc.navigationHistory.canGoBack();
1289 const canGoForward = wc.navigationHistory.canGoForward();
1290 const updatedAt = yield* currentIso;
1291 const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1292 const current = tabs.get(tabId);
1293 if (!current || current.webContentsId !== wc.id || webContents.fromId(wc.id) !== wc) {
1294 return [Option.none<PreviewTabState>(), tabs] as const;
1295 }
1296 // Electron emits did-stop-loading after did-fail-load. At that point the
1297 // failed guest is no longer "loading", but it has not successfully
1298 // navigated anywhere. Keep the failure until a new load actually starts.
1299 const navStatus =
1300 preserveLoadFailure &&
1301 current.navStatus.kind === "LoadFailed" &&
1302 computedNavStatus.kind === "Success"
1303 ? current.navStatus
1304 : computedNavStatus;
1305 const clearFavicon =
1306 confirmedNavigation &&
1307 current.favicon !== undefined &&
1308 safeHttpOrigin(current.favicon.pageUrl) !==
1309 safeHttpOrigin(navStatus.kind === "Idle" ? wc.getURL() : navStatus.url);
1310 const { favicon: _favicon, ...currentWithoutFavicon } = current;
1311 const state: PreviewTabState = {
1312 ...(clearFavicon ? currentWithoutFavicon : current),
1313 navStatus,
1314 canGoBack,
1315 canGoForward,
1316 // zoomFactor is deliberately not read back from the guest: Chromium
1317 // reports the level it inherited from the app window, so mirroring it
1318 // would turn an app zoom into the preview's own zoom.
1319 updatedAt,
1320 };
1321 return [
1322 Option.some(state),
1323 replaceMap(tabs, (copy) => {
1324 copy.set(tabId, state);
1325 }),
1326 ] as const;
1327 });
1328 if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value);
1329 });
1330 const sync = () => runFork(syncState(true));
1331 const syncNavigation = () => runFork(syncState(false, true));
1332 const syncInPageNavigation = () => runFork(syncState(false));
1333 const restoreRecordingCursor = () =>
1334 runFork(
1335 Effect.gen(function* () {
1336 const session = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId);
1337 if (!wc.isDestroyed()) {
1338 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
1339 wc.send(
1340 RECORDING_CURSOR_CHANNEL,
1341 session?.consumers.has("recording") ?? false,
1342 session?.recordingInputOptions,
1343 tab?.controller,
1344 );
1345 }
1346 }),
1347 );
1348 const navigationStarted = (
1349 event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
1350 ) => {
1351 if (event.isMainFrame && !event.isSameDocument) cancelFaviconCapture();
1352 };
1353 const audioStateChanged = (
1354 event: Electron.Event<Electron.WebContentsAudioStateChangedEventParams>,
1355 ) => runFork(syncTabAudible(tabId, wc, event.audible));
1356 const publishFavicon = Effect.fn("PreviewManager.publishFavicon")(function* (input: {
1357 readonly captureDocumentId: number;
1358 readonly dataUrl: string;
1359 readonly pageUrl: string;
1360 readonly requestId: number;
1361 }) {
1362 const pageOrigin = safeHttpOrigin(input.pageUrl);
1363 const managed = (yield* Ref.get(attachedRef)).get(wc.id);
1364 if (
1365 !pageOrigin ||
1366 wc.isDestroyed() ||
1367 webContents.fromId(wc.id) !== wc ||
1368 managed?.attachmentId !== attachmentId ||
1369 activeCapture?.documentId !== input.captureDocumentId ||
1370 activeCapture.requestId !== input.requestId ||
1371 safeHttpOrigin(wc.getURL()) !== pageOrigin
1372 ) {
1373 return;
1374 }
1375 const capturedAt = yield* currentMillis;
1376 const updatedAt = yield* currentIso;
1377 const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1378 const current = tabs.get(tabId);
1379 if (
1380 !current ||
1381 current.webContentsId !== wc.id ||
1382 webContents.fromId(wc.id) !== wc ||
1383 activeCapture?.documentId !== input.captureDocumentId ||
1384 activeCapture.requestId !== input.requestId
1385 ) {
1386 return [Option.none<PreviewTabState>(), tabs] as const;
1387 }
1388 const state: PreviewTabState = {
1389 ...current,
1390 favicon: { dataUrl: input.dataUrl, pageUrl: pageOrigin, capturedAt },
1391 updatedAt,
1392 };
1393 return [
1394 Option.some(state),
1395 replaceMap(tabs, (copy) => {
1396 copy.set(tabId, state);
1397 }),
1398 ] as const;
1399 });
1400 if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value);
1401 });
1402 const faviconUpdated = (_event: Event, rawCandidates: ReadonlyArray<string>): void => {
1403 const pageUrl = wc.getURL();
1404 if (!safeHttpOrigin(pageUrl)) return;
1405 const candidates = selectFaviconCandidates(rawCandidates);
1406 if (candidates.length === 0) return;
1407 const eventKey = JSON.stringify([pageUrl, ...candidates]);
1408 if (activeCapture?.eventKey === eventKey) return;
1409 activeCapture?.controller.abort();
1410 const captureDocumentId = documentId;
1411 const requestId = ++nextRequestId;
1412 const controller = new AbortController();
1413 activeCapture = { controller, documentId: captureDocumentId, eventKey, requestId };
1414 runFork(
1415 Effect.tryPromise({
1416 try: () =>
1417 captureFavicon({ webContents: wc, pageUrl, candidates, signal: controller.signal }),
1418 catch: (cause) =>
1419 new PreviewOperationError({
1420 operation: "captureFavicon",
1421 tabId,
1422 webContentsId: wc.id,
1423 cause,
1424 }),
1425 }).pipe(
1426 Effect.flatMap((result) =>
1427 result.kind === "captured"
1428 ? publishFavicon({
1429 captureDocumentId,
1430 dataUrl: result.dataUrl,
1431 pageUrl,
1432 requestId,
1433 })
1434 : Effect.void,
1435 ),
1436 Effect.catch((error) =>
1437 controller.signal.aborted
1438 ? Effect.void
1439 : Effect.logDebug("Favicon capture failed.", { error, tabId, webContentsId: wc.id }),
1440 ),
1441 Effect.ensuring(
1442 Effect.sync(() => {
1443 if (activeCapture?.requestId === requestId) activeCapture = null;
1444 }),
1445 ),
1446 ),
1447 );
1448 };
1449 const failed = (
1450 _event: Event,
1451 code: number,
1452 description: string,
1453 validatedUrl: string,
1454 isMainFrame: boolean,
1455 ): void => {
1456 if (code === -3 || !isMainFrame) return;
1457 runFork(
1458 update(tabId, {
1459 navStatus: {
1460 kind: "LoadFailed",
1461 url: validatedUrl || wc.getURL(),
1462 title: wc.getTitle(),
1463 code,
1464 description,
1465 },
1466 }),
1467 );
1468 };
1469 const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* (
1470 rawSignal?: unknown,
1471 ) {
1472 yield* update(
1473 tabId,
1474 { controller: "human" },
1475 isPreviewInputSignal(rawSignal) && rawSignal.kind === "pointer"
1476 ? { x: rawSignal.x, y: rawSignal.y }
1477 : undefined,
1478 );
1479 yield* Effect.sleep(750);
1480 const tabs = yield* SynchronizedRef.get(tabsRef);
1481 if (tabs.get(tabId)?.controller === "human") {
1482 yield* update(tabId, { controller: "none" });
1483 }
1484 });
1485 const recordingInput = (_event: unknown, input: unknown) => {
1486 if (!isRecordingInput(input)) return;
1487 return runFork(
1488 Effect.gen(function* () {
1489 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
1490 const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId);
1491 if (tab?.webContentsId !== wc.id || !capture?.consumers.has("recording")) return;
1492 if (input.type === "key" && !capture.recordingInputOptions?.showKeyPresses) return;
1493 if (input.type === "pointer" && !capture.recordingInputOptions?.showMousePresses) return;
1494 const listeners = yield* Ref.get(recordingInputListenersRef);
1495 yield* Effect.forEach(
1496 listeners,
1497 (listener) => deliverEvent("recording-input", tabId, () => listener({ tabId, input })),
1498 { discard: true },
1499 );
1500 }),
1501 );
1502 };
1503 const humanInput = (_event: unknown, rawSignal?: unknown): void => {
1504 runFork(handleHumanInput(rawSignal));
1505 };
1506 const mouseNavigate = (_event: unknown, payload?: unknown): void => {
1507 const direction =
1508 typeof payload === "object" && payload !== null && "direction" in payload
1509 ? (payload as { direction?: unknown }).direction
1510 : undefined;
1511 if (direction !== "back" && direction !== "forward") return;
1512 runFork(
1513 attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => {
1514 if (direction === "back") {
1515 if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack();
1516 } else if (wc.navigationHistory.canGoForward()) {
1517 wc.navigationHistory.goForward();
1518 }
1519 }).pipe(Effect.ignore),
1520 );
1521 };
1522 const syncMenuShortcuts = (contents: Electron.WebContents, input: Electron.Input): void => {
1523 if (input.type !== "keyDown") return;
1524 // Native editing roles must remain available after the page handles the key.
1525 // Background automation must not edit whichever other renderer has focus.
1526 contents.setIgnoreMenuShortcuts(
1527 !isPreviewEditingShortcut(input, hostPlatform) ||
1528 webContents.getFocusedWebContents() !== contents,
1529 );
1530 };
1531 // A popup opens with Electron's default handler, so the page inside it could
1532 // otherwise spawn native windows without limit. Nothing in an OAuth flow
1533 // opens a second popup, so the chain stops at the first one.
1534 const windowCreated = (window: Electron.BrowserWindow): void => {
1535 const popup = window.webContents;
1536 popup.once("destroyed", () => runFork(detachControlSession(popup.id)));
1537 runFork(ensureControlSession(popup).pipe(Effect.ignoreCause({ log: true })));
1538 window.webContents.setIgnoreMenuShortcuts(true);
1539 window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
1540 window.webContents.on("before-input-event", (_event, input) => {
1541 syncMenuShortcuts(window.webContents, input);
1542 });
1543 };
1544 const beforeInput = (event: Electron.Event, input: Electron.Input): void => {
1545 syncMenuShortcuts(wc, input);
1546 const host = wc.hostWebContents;
1547 const forwarded =
1548 input.type === "keyDown" &&
1549 !input.isComposing &&
1550 host &&
1551 !host.isDestroyed() &&
1552 webContents.getFocusedWebContents() === wc &&
1553 !(
1554 hostPlatform !== "darwin" &&
1555 input.control &&
1556 input.alt &&
1557 /^(?:[^a-zA-Z0-9]|Dead)$/u.test(input.key)
1558 ) &&
1559 forwardedShortcuts.find(({ shortcut }) =>
1560 matchesKeybindingShortcut(
1561 {
1562 key: input.key,
1563 code: input.code,
1564 metaKey: input.meta,
1565 ctrlKey: input.control,
1566 shiftKey: input.shift,
1567 altKey: input.alt,
1568 },
1569 shortcut,
1570 hostPlatform === "darwin" ? "MacIntel" : hostPlatform,
1571 ),
1572 );
1573 if (forwarded && host) {
1574 event.preventDefault();
1575 if (!input.isAutoRepeat) host.send(MENU_ACTION_CHANNEL, forwarded.command);
1576 return;
1577 }
1578 if (isPreviewRefreshShortcut(input)) {
1579 event.preventDefault();
1580 runFork(
1581 attempt({ operation: "shortcut.refresh", tabId, webContentsId: wc.id }, () =>
1582 wc.reload(),
1583 ).pipe(Effect.ignore),
1584 );
1585 return;
1586 }
1587 };
1588 yield* Scope.addFinalizer(
1589 scope,
1590 attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => {
1591 cancelFaviconCapture();
1592 wc.off("did-start-navigation", navigationStarted);
1593 wc.off("did-navigate", syncNavigation);
1594 wc.off("did-navigate-in-page", syncInPageNavigation);
1595 wc.off("page-title-updated", sync);
1596 wc.off("page-favicon-updated", faviconUpdated as never);
1597 wc.off("did-start-loading", sync);
1598 wc.off("did-stop-loading", sync);
1599 wc.off("dom-ready", restoreRecordingCursor);
1600 wc.off("did-fail-load", failed as never);
1601 wc.off("audio-state-changed", audioStateChanged);
1602 wc.off("did-create-window", windowCreated);
1603 wc.off("before-input-event", beforeInput);
1604 wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput);
1605 wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput);
1606 wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
1607 detachPasskeys();
1608 }).pipe(Effect.ignore),
1609 );
1610 const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
1611 yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => {
1612 // Only focused native editing shortcuts may reach the application menu.
1613 // Other preview input, including CDP keys, belongs to the page.
1614 wc.setIgnoreMenuShortcuts(true);
1615 wc.on("did-start-navigation", navigationStarted);
1616 wc.on("did-navigate", syncNavigation);
1617 wc.on("did-navigate-in-page", syncInPageNavigation);
1618 wc.on("page-title-updated", sync);
1619 wc.on("page-favicon-updated", faviconUpdated as never);
1620 wc.on("did-start-loading", sync);
1621 wc.on("did-stop-loading", sync);
1622 wc.on("dom-ready", restoreRecordingCursor);
1623 wc.on("did-fail-load", failed as never);
1624 wc.on("audio-state-changed", audioStateChanged);
1625 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
1626 wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput);
1627 wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
1628 detachPasskeys = passkeys.attachGuest(wc);
1629 wc.setWindowOpenHandler((details) => {
1630 const action = previewWindowOpenAction(details);
1631 if (action === "popup") {
1632 return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS };
1633 }
1634 if (action === "deny") return { action: "deny" };
1635 if (action === "new-tab") {
1636 runFork(
1637 emitOpenLink({
1638 tabId,
1639 url: details.url,
1640 background: details.disposition === "background-tab",
1641 }),
1642 );
1643 return { action: "deny" };
1644 }
1645 runFork(
1646 attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () =>
1647 wc.loadURL(details.url),
1648 ).pipe(Effect.ignore),
1649 );
1650 return { action: "deny" };
1651 });
1652 wc.on("did-create-window", windowCreated);
1653 wc.on("before-input-event", beforeInput);
1654 });
1655 yield* Ref.update(attachedRef, (attached) =>
1656 replaceMap(attached, (copy) => {
1657 copy.set(wc.id, { attachmentId, cancelFaviconCapture, scope, webContents: wc });
1658 }),
1659 );
1660 });
1661 yield* install().pipe(Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)));
1662 });
1663
1664 const setMainWindow = Effect.fn("PreviewManager.setMainWindow")(function* (
1665 window: BrowserWindow,
1666 ) {
1667 if (mainWindowCleanupFiber) {
1668 yield* Fiber.join(mainWindowCleanupFiber);
1669 mainWindowCleanupFiber = undefined;
1670 }
1671 yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) =>
1672 Effect.gen(function* () {
1673 if (sessions.size > 0) {
1674 yield* setWindowBackgroundThrottling(window, false);
1675 }
1676 yield* Ref.set(mainWindowRef, Option.some(window));
1677 currentMainWindow = window;
1678 frameCaptureWindowOpen = true;
1679 window.once("closed", () => {
1680 if (currentMainWindow !== window) return;
1681 currentMainWindow = undefined;
1682 frameCaptureWindowOpen = false;
1683 mainWindowCleanupFiber = runFork(
1684 Effect.all([closeAllPictureInPicture(), stopAllRecordings()], {
1685 concurrency: "unbounded",
1686 discard: true,
1687 }).pipe(Effect.ignore),
1688 );
1689 });
1690 return [undefined, sessions] as const;
1691 }),
1692 ).pipe(Effect.uninterruptible);
1693 });
1694
1695 const createTabUnlocked = Effect.fn("PreviewManager.createTabUnlocked")(function* (
1696 tabId: string,
1697 defaults?: DesktopPreviewTabDefaults,
1698 ) {
1699 const updatedAt = yield* currentIso;
1700 const result = yield* SynchronizedRef.modify(
1701 tabsRef,
1702 (
1703 tabs,
1704 ): readonly [
1705 { readonly state: PreviewTabState; readonly created: boolean },
1706 ReadonlyMap<string, PreviewTabState>,
1707 ] => {
1708 const existing = tabs.get(tabId);
1709 if (existing) return [{ state: existing, created: false }, tabs] as const;
1710 const initial: PreviewTabState = {
1711 tabId,
1712 webContentsId: null,
1713 navStatus: { kind: "Idle" },
1714 canGoBack: false,
1715 canGoForward: false,
1716 zoomFactor: normalizeZoomFactor(defaults?.zoomFactor),
1717 pictureInPicture: false,
1718 colorScheme: defaults?.colorScheme ?? "system",
1719 ...(defaults?.serverTab === undefined ? {} : { serverTab: defaults.serverTab }),
1720 audioMuted: false,
1721 audible: false,
1722 controller: "none",
1723 updatedAt,
1724 };
1725 return [
1726 { state: initial, created: true },
1727 replaceMap(tabs, (copy) => {
1728 copy.set(tabId, initial);
1729 }),
1730 ] as const;
1731 },
1732 );
1733 if (result.created) {
1734 tabLifecycleGenerations.set(tabId, (tabLifecycleGenerations.get(tabId) ?? 0) + 1);
1735 }
1736 yield* emit(tabId, result.state);
1737 return result.state;
1738 });
1739
1740 const createTab = Effect.fn("PreviewManager.createTab")(function* (
1741 tabId: string,
1742 defaults?: DesktopPreviewTabDefaults,
1743 ) {
1744 return yield* withTabLifecycleLock(tabId, createTabUnlocked(tabId, defaults));
1745 });
1746
1747 const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) {
1748 if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return;
1749 clearPendingRecording(tabId);
1750 annotationSendEnabled.delete(tabId);
1751 yield* Effect.all(
1752 [
1753 cancelPickElement(tabId),
1754 closePictureInPicture(tabId),
1755 stopFrameCapture(tabId, "recording"),
1756 ],
1757 {
1758 concurrency: 3,
1759 discard: true,
1760 },
1761 );
1762 const tab = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1763 const current = tabs.get(tabId);
1764 if (!current) return [Option.none<PreviewTabState>(), tabs] as const;
1765 return [
1766 Option.some(current),
1767 replaceMap(tabs, (copy) => {
1768 copy.delete(tabId);
1769 }),
1770 ] as const;
1771 });
1772 if (Option.isNone(tab)) return;
1773 const closedTab = tab.value;
1774 if (closedTab.webContentsId != null) {
1775 yield* Effect.all(
1776 [
1777 detachControlSession(closedTab.webContentsId, closedTab.serverTab),
1778 detachListeners(closedTab.webContentsId),
1779 ],
1780 { concurrency: 2, discard: true },
1781 );
1782 }
1783 const updatedAt = yield* currentIso;
1784 const closed: PreviewTabState = {
1785 ...closedTab,
1786 webContentsId: null,
1787 navStatus: { kind: "Idle" },
1788 canGoBack: false,
1789 canGoForward: false,
1790 zoomFactor: DEFAULT_ZOOM_FACTOR,
1791 pictureInPicture: false,
1792 colorScheme: "system",
1793 audioMuted: false,
1794 audible: false,
1795 controller: "none",
1796 updatedAt,
1797 };
1798 yield* emit(tabId, closed);
1799 });
1800
1801 const closeTab = Effect.fn("PreviewManager.closeTab")(function* (tabId: string) {
1802 const claimed = yield* Ref.modify(closingTabIdsRef, (closingTabIds) => {
1803 if (closingTabIds.has(tabId)) return [false, closingTabIds] as const;
1804 return [true, new Set([...closingTabIds, tabId])] as const;
1805 });
1806 if (!claimed) return;
1807 return yield* withTabLifecycleLock(tabId, closeTabUnlocked(tabId)).pipe(
1808 Effect.ensuring(
1809 Ref.update(closingTabIdsRef, (closingTabIds) => {
1810 if (!closingTabIds.has(tabId)) return closingTabIds;
1811 const next = new Set(closingTabIds);
1812 next.delete(tabId);
1813 return next;
1814 }),
1815 ),
1816 );
1817 });
1818
1819 const registerWebviewUnlocked = Effect.fn("PreviewManager.registerWebviewUnlocked")(function* (
1820 tabId: string,
1821 webContentsId: number,
1822 expectedGeneration: number | undefined,
1823 ) {
1824 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
1825 if (
1826 !tab ||
1827 tabLifecycleGenerations.get(tabId) !== expectedGeneration ||
1828 (yield* Ref.get(closingTabIdsRef)).has(tabId)
1829 ) {
1830 return yield* new PreviewTabNotFoundError({ tabId });
1831 }
1832 const wc = webContents.fromId(webContentsId);
1833 const mainWindow = yield* Ref.get(mainWindowRef);
1834 if (
1835 !wc ||
1836 wc.isDestroyed() ||
1837 wc.getType() !== "webview" ||
1838 (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents)
1839 ) {
1840 return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId });
1841 }
1842 yield* rendererHistory.register(wc, { surface: "preview", tabId });
1843 const attached = yield* Ref.get(attachedRef);
1844 const annotationTheme = yield* Ref.get(annotationThemeRef);
1845 const currentAttachment = attached.get(webContentsId);
1846 yield* keepFrameCaptureWebContentsUnthrottled(tabId, wc);
1847 if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) {
1848 // The guest we already own re-announced itself, so nothing about the tab
1849 // changed. Only push its zoom back down — Chromium may have just handed
1850 // this guest the app window's zoom level.
1851 yield* assertTabZoom(tabId);
1852 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => {
1853 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme);
1854 wc.send(ANNOTATION_SEND_ENABLED_CHANNEL, annotationSendEnabled.get(tabId) === true);
1855 });
1856 return;
1857 }
1858 const replacedWebContentsId =
1859 tab.webContentsId != null &&
1860 (tab.webContentsId !== webContentsId || currentAttachment?.webContents !== wc)
1861 ? tab.webContentsId
1862 : null;
1863 if (replacedWebContentsId !== null) {
1864 // The replaced guest can no longer redeem a display-media grant.
1865 clearPendingRecording(tabId);
1866 yield* Effect.all(
1867 [
1868 detachControlSession(replacedWebContentsId),
1869 detachListeners(replacedWebContentsId),
1870 cancelPickElement(tabId),
1871 ],
1872 { concurrency: 3, discard: true },
1873 );
1874 }
1875 const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
1876 if (
1877 !currentTab ||
1878 tabLifecycleGenerations.get(tabId) !== expectedGeneration ||
1879 (yield* Ref.get(closingTabIdsRef)).has(tabId)
1880 ) {
1881 return yield* new PreviewTabNotFoundError({ tabId });
1882 }
1883 // Always assert the tab's own zoom rather than reading the guest's: a guest
1884 // attaching while the app UI is zoomed starts at the embedder's inherited
1885 // zoom level, which is not the preview's zoom. Done before the guest is
1886 // published so it never paints a frame at the inherited zoom.
1887 yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () =>
1888 wc.setZoomFactor(currentTab.zoomFactor),
1889 );
1890 // A replacement guest attaches unmuted, so reassert the tab's mute before it
1891 // is published rather than letting it emit audio the user already silenced.
1892 // Settled again after attach, below, the same way zoom is.
1893 yield* attempt({ operation: "registerWebview.restoreAudioMuted", tabId, webContentsId }, () =>
1894 wc.setAudioMuted(currentTab.audioMuted),
1895 );
1896 yield* attachListeners(tabId, wc);
1897 const readAudible = attempt(
1898 { operation: "registerWebview.readAudible", tabId, webContentsId },
1899 () => wc.isCurrentlyAudible(),
1900 ).pipe(Effect.orElseSucceed(() => false));
1901 const attachedAudible = yield* readAudible;
1902 const registeredAt = yield* currentIso;
1903 const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) =>
1904 Effect.gen(function* () {
1905 const current = tabs.get(tabId);
1906 if (
1907 !current ||
1908 tabLifecycleGenerations.get(tabId) !== expectedGeneration ||
1909 (yield* Ref.get(closingTabIdsRef)).has(tabId)
1910 ) {
1911 return [
1912 Option.none<{ readonly state: PreviewTabState; readonly pendingUrl: string | null }>(),
1913 tabs,
1914 ] as const;
1915 }
1916 const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null;
1917 const { favicon: _favicon, ...currentWithoutFavicon } = current;
1918 const next: PreviewTabState = {
1919 ...currentWithoutFavicon,
1920 webContentsId,
1921 navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,
1922 canGoBack: wc.navigationHistory.canGoBack(),
1923 canGoForward: wc.navigationHistory.canGoForward(),
1924 audible: attachedAudible,
1925 updatedAt: registeredAt,
1926 };
1927 return [
1928 Option.some({
1929 state: next,
1930 pendingUrl,
1931 }),
1932 replaceMap(tabs, (copy) => {
1933 copy.set(tabId, next);
1934 }),
1935 ] as const;
1936 }),
1937 );
1938 if (Option.isNone(registration)) {
1939 yield* Effect.all([detachControlSession(webContentsId), detachListeners(webContentsId)], {
1940 concurrency: 2,
1941 discard: true,
1942 });
1943 return yield* new PreviewTabNotFoundError({ tabId });
1944 }
1945 const { state: registered, pendingUrl } = registration.value;
1946 // A zoom or mute action that landed while this attach was in flight
1947 // addressed the guest this one replaced, so settle the new guest on the
1948 // committed values.
1949 yield* assertTabZoom(tabId);
1950 // Best-effort here, unlike in setAudioMuted: a guest that dies mid-attach
1951 // must not fail the registration it was attaching for.
1952 yield* assertTabAudioMuted(tabId).pipe(Effect.ignore);
1953 runFork(restoreControlSession(tabId, wc));
1954 // emitIfCurrent, not emit: audio-state-changed can land between the commit
1955 // above and here, and republishing this snapshot would roll the UI back to
1956 // a superseded audibility that syncTabAudible will not re-send.
1957 yield* emitIfCurrent(tabId, registered);
1958 // Transitions that fired before the tab owned this guest were dropped by
1959 // syncTabAudible's ownership check, so re-read and reconcile through the
1960 // same path the event uses.
1961 yield* syncTabAudible(tabId, wc, yield* readAudible);
1962 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => {
1963 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme);
1964 wc.send(ANNOTATION_SEND_ENABLED_CHANNEL, annotationSendEnabled.get(tabId) === true);
1965 });
1966 const latestNavStatus = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.navStatus;
1967 if (
1968 pendingUrl &&
1969 latestNavStatus?.kind === "Loading" &&
1970 latestNavStatus.url === pendingUrl &&
1971 wc.getURL() !== pendingUrl
1972 ) {
1973 runFork(
1974 attemptPromise({ operation: "registerWebview.loadPendingUrl", tabId, webContentsId }, () =>
1975 wc.loadURL(pendingUrl),
1976 ).pipe(Effect.ignore),
1977 );
1978 }
1979 });
1980
1981 const registerWebview = Effect.fn("PreviewManager.registerWebview")(function* (
1982 tabId: string,
1983 webContentsId: number,
1984 ) {
1985 const expectedGeneration = tabLifecycleGenerations.get(tabId);
1986 return yield* withTabLifecycleLock(
1987 tabId,
1988 registerWebviewUnlocked(tabId, webContentsId, expectedGeneration),
1989 );
1990 });
1991
1992 // Called when a guest attaches to the window, before its first document
1993 // paints. A tab opened straight to a URL often paints before the renderer
1994 // gets to registerWebview, which would leave that page on the transparent
1995 // base. registerWebview reuses the session opened here.
1996 const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* (
1997 wc: Electron.WebContents,
1998 ) {
1999 yield* rendererHistory.register(wc, { surface: "preview" });
2000 const webContentsId = wc.id;
2001 // A guest destroyed before any tab claims it has no other cleanup path.
2002 wc.once("destroyed", () => {
2003 runFork(detachControlSession(webContentsId));
2004 });
2005 // Runs detached from the attach event, so nothing may escape. registerWebview
2006 // opens the session again if this one did not.
2007 yield* ensureControlSession(wc).pipe(
2008 Effect.catchCause((cause) =>
2009 Effect.logDebug("Preview webview control session was not opened on attach.", {
2010 webContentsId,
2011 cause,
2012 }),
2013 ),
2014 );
2015 });
2016
2017 const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) {
2018 const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () =>
2019 normalizePreviewUrl(rawUrl),
2020 );
2021 const updatedAt = yield* currentIso;
2022 const pending = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
2023 const current = tabs.get(tabId);
2024 const next: PreviewTabState = {
2025 tabId,
2026 webContentsId: current?.webContentsId ?? null,
2027 navStatus: {
2028 kind: "Loading",
2029 url,
2030 title: current?.navStatus.kind === "Idle" || !current ? "" : current.navStatus.title,
2031 },
2032 canGoBack: current?.canGoBack ?? false,
2033 canGoForward: current?.canGoForward ?? false,
2034 zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR,
2035 pictureInPicture: current?.pictureInPicture ?? false,
2036 colorScheme: current?.colorScheme ?? "system",
2037 // Both carry across navigation. Mute is user intent, and the old
2038 // document keeps playing until loadURL actually replaces it, so
2039 // clearing audibility here would drop the speaker with no transition
2040 // left to restore it. Chromium reports the change when it happens.
2041 audioMuted: current?.audioMuted ?? false,
2042 audible: current?.audible ?? false,
2043 controller: current?.controller ?? "none",
2044 ...(current?.favicon ? { favicon: current.favicon } : {}),
2045 updatedAt,
2046 };
2047 return [
2048 next,
2049 replaceMap(tabs, (copy) => {
2050 copy.set(tabId, next);
2051 }),
2052 ] as const;
2053 });
2054 // emitIfCurrent for the same reason as update: this snapshot carries
2055 // audibility forward, and an audio-state-changed landing in between would
2056 // otherwise be rolled back with no follow-up transition to correct it.
2057 yield* emitIfCurrent(tabId, pending);
2058 if (pending.webContentsId == null) return;
2059 const webContentsId = pending.webContentsId;
2060 const wc = webContents.fromId(webContentsId);
2061 if (!wc || wc.isDestroyed()) {
2062 const expectedAttachment = (yield* Ref.get(attachedRef)).get(webContentsId);
2063 yield* withTabLifecycleLock(
2064 tabId,
2065 Effect.gen(function* () {
2066 const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
2067 const currentAttachment = (yield* Ref.get(attachedRef)).get(webContentsId);
2068 const currentWebContents = webContents.fromId(webContentsId);
2069 if (
2070 currentTab?.webContentsId !== webContentsId ||
2071 currentAttachment !== expectedAttachment ||
2072 (currentWebContents && !currentWebContents.isDestroyed())
2073 ) {
2074 return;
2075 }
2076 yield* Effect.all(
2077 [
2078 detachControlSession(webContentsId),
2079 detachListeners(webContentsId),
2080 cancelPickElement(tabId),
2081 ],
2082 { concurrency: 3, discard: true },
2083 );
2084 const detached = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
2085 const current = tabs.get(tabId);
2086 if (current?.webContentsId !== webContentsId) {
2087 return [Option.none<PreviewTabState>(), tabs] as const;
2088 }
2089 const { favicon: _favicon, ...currentWithoutFavicon } = current;
2090 const next: PreviewTabState = { ...currentWithoutFavicon, webContentsId: null };
2091 return [
2092 Option.some(next),
2093 replaceMap(tabs, (copy) => {
2094 copy.set(tabId, next);
2095 }),
2096 ] as const;
2097 });
2098 if (Option.isSome(detached)) yield* emitIfCurrent(tabId, detached.value);
2099 }),
2100 );
2101 return;
2102 }
2103 // URL-bar navigation hands the page back to the human. The agent's own
2104 // navigation shares this path; its next action marks the page again.
2105 if (wc.getURL() === url) {
2106 yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () =>
2107 wc.reload(),
2108 );
2109 return;
2110 }
2111 yield* attemptPromise({ operation: "navigate.loadURL", tabId, webContentsId: wc.id }, () =>
2112 wc.loadURL(url),
2113 );
2114 });
2115
2116 const withWebContents = Effect.fn("PreviewManager.withWebContents")(function* (
2117 operation: string,
2118 tabId: string,
2119 use: (wc: Electron.WebContents) => void,
2120 ) {
2121 const wc = yield* requireWebContents(tabId);
2122 yield* attempt({ operation, tabId, webContentsId: wc.id }, () => use(wc));
2123 });
2124
2125 const goBack = (tabId: string) =>
2126 withWebContents("goBack", tabId, (wc) => {
2127 if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack();
2128 });
2129 const goForward = (tabId: string) =>
2130 withWebContents("goForward", tabId, (wc) => {
2131 if (wc.navigationHistory.canGoForward()) wc.navigationHistory.goForward();
2132 });
2133 const refresh = (tabId: string) => withWebContents("refresh", tabId, (wc) => wc.reload());
2134 const hardReload = (tabId: string) =>
2135 withWebContents("hardReload", tabId, (wc) => wc.reloadIgnoringCache());
2136
2137 const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) {
2138 const wc = yield* requireWebContents(tabId);
2139 if (wc.isDevToolsOpened()) {
2140 yield* attempt({ operation: "openDevTools.focus", tabId, webContentsId: wc.id }, () =>
2141 wc.devToolsWebContents?.focus(),
2142 );
2143 return;
2144 }
2145 yield* detachControlSession(wc.id);
2146 yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => {
2147 wc.once("devtools-closed", () => {
2148 if (!wc.isDestroyed()) runFork(restoreControlSession(tabId, wc));
2149 });
2150 wc.openDevTools({ mode: "detach" });
2151 });
2152 });
2153
2154 const setAnnotationTheme = Effect.fn("PreviewManager.setAnnotationTheme")(function* (
2155 theme: DesktopPreviewAnnotationTheme,
2156 ) {
2157 yield* Ref.set(annotationThemeRef, theme);
2158 const tabs = yield* SynchronizedRef.get(tabsRef);
2159 yield* Effect.forEach(
2160 tabs.values(),
2161 (tab) => {
2162 if (tab.webContentsId == null) return Effect.void;
2163 const wc = webContents.fromId(tab.webContentsId);
2164 return !wc || wc.isDestroyed()
2165 ? Effect.void
2166 : attempt(
2167 {
2168 operation: "setAnnotationTheme",
2169 tabId: tab.tabId,
2170 webContentsId: tab.webContentsId,
2171 },
2172 () => wc.send(ANNOTATION_THEME_CHANNEL, theme),
2173 ).pipe(Effect.ignore);
2174 },
2175 { discard: true },
2176 );
2177 });
2178
2179 const setAnnotationSendEnabled = Effect.fn("PreviewManager.setAnnotationSendEnabled")(function* (
2180 tabId: string,
2181 enabled: boolean,
2182 ) {
2183 const wc = yield* requireWebContents(tabId);
2184 yield* attempt({ operation: "setAnnotationSendEnabled", tabId, webContentsId: wc.id }, () => {
2185 annotationSendEnabled.set(tabId, enabled);
2186 wc.send(ANNOTATION_SEND_ENABLED_CHANNEL, enabled);
2187 });
2188 });
2189
2190 const pickElement = Effect.fn("PreviewManager.pickElement")(function* (tabId: string) {
2191 const wc = yield* requireWebContents(tabId);
2192 yield* cancelPickElement(tabId);
2193 const annotationTheme = yield* Ref.get(annotationThemeRef);
2194 return yield* Effect.callback<PreviewAnnotationSubmissionResult | null, PreviewManagerError>(
2195 (resume) => {
2196 // Declared first so cleanup can check slot ownership by identity
2197 // without a type cycle through the cancel effect it builds.
2198 const session: PickSession = { cancel: Effect.suspend(() => cancelPickSession()) };
2199 const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () {
2200 yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => {
2201 wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage);
2202 wc.off("destroyed", onDestroyed);
2203 wc.off("did-start-navigation", onNavigated);
2204 }).pipe(Effect.ignore);
2205 // Only drop the slot while it is still ours. A newer session may
2206 // already have swapped itself in before cancelling this one.
2207 yield* Ref.update(pickSessionsRef, (sessions) =>
2208 sessions.get(tabId) === session
2209 ? replaceMap(sessions, (copy) => {
2210 copy.delete(tabId);
2211 })
2212 : sessions,
2213 );
2214 });
2215 // Every exit from this session runs through `claimSettle`, so the
2216 // renderer's `pickElement` promise resolves exactly once. The previous
2217 // identity check let a cancelled or replaced session return without
2218 // resuming, which left the composer waiting forever.
2219 let settled = false;
2220 const claimSettle = (): boolean => {
2221 if (settled) return false;
2222 settled = true;
2223 return true;
2224 };
2225 const finishPick = Effect.fn("PreviewManager.finishPickElement")(function* (
2226 payload: PreviewAnnotationSubmissionResult | null,
2227 ) {
2228 yield* cleanup();
2229 resume(Effect.succeed(payload));
2230 });
2231 const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* (
2232 payload: PreviewAnnotationSubmissionResult | null,
2233 ) {
2234 if (!claimSettle()) return;
2235 yield* finishPick(payload);
2236 });
2237 const settle = (payload: PreviewAnnotationSubmissionResult | null) => {
2238 runFork(settlePick(payload));
2239 };
2240 const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () {
2241 if (!claimSettle()) return;
2242 yield* cleanup();
2243 const tabs = yield* SynchronizedRef.get(tabsRef);
2244 const activeTab = tabs.get(tabId);
2245 if (activeTab?.webContentsId != null) {
2246 const activeWc = webContents.fromId(activeTab.webContentsId);
2247 if (activeWc && !activeWc.isDestroyed()) {
2248 yield* attempt(
2249 {
2250 operation: "cancelPickElement",
2251 tabId,
2252 webContentsId: activeWc.id,
2253 },
2254 () => activeWc.send(CANCEL_PICK_CHANNEL),
2255 ).pipe(Effect.ignore);
2256 }
2257 }
2258 resume(Effect.succeed(null));
2259 });
2260 const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => {
2261 const payload = args[0];
2262 if (!isPreviewAnnotationPayload(payload)) {
2263 settle(null);
2264 return;
2265 }
2266 const cropRect = normalizeCaptureRect(args[1]);
2267 const submission =
2268 args[2] === "send" && annotationSendEnabled.get(tabId) === true ? "send" : "attach";
2269 const devicePixelRatio = typeof args[3] === "number" ? args[3] : 1;
2270 runFork(
2271 // The full-page capture with retries is what the screenshot button
2272 // uses. A single cropped `capturePage(rect)` with a fixed timeout
2273 // dropped the crop on pages where that capture was merely slow.
2274 capturePageWithRetry(
2275 { operation: "captureAnnotationScreenshot", tabId, webContentsId: wc.id },
2276 tabId,
2277 wc,
2278 ).pipe(
2279 Effect.map((image) => cropAnnotationScreenshot(image, cropRect, devicePixelRatio)),
2280 Effect.tapError((error) =>
2281 Effect.logWarning("preview annotation screenshot failed").pipe(
2282 Effect.annotateLogs({ tabId, webContentsId: wc.id, error: error.message }),
2283 ),
2284 ),
2285 Effect.withSpan("PreviewManager.captureAnnotationScreenshot"),
2286 // The renderer cannot tell a dropped crop from a comment-only
2287 // pick by the null alone, so a failed or timed-out capture is
2288 // flagged on the result.
2289 Effect.match({
2290 onFailure: (): PreviewAnnotationSubmissionResult => ({
2291 annotation: payload,
2292 submission,
2293 screenshotFailed: true,
2294 }),
2295 onSuccess: (screenshot): PreviewAnnotationSubmissionResult =>
2296 screenshot === null
2297 ? { annotation: payload, submission, screenshotFailed: true }
2298 : { annotation: { ...payload, screenshot }, submission },
2299 }),
2300 Effect.flatMap((result) => {
2301 // A capture that outlives its session must not touch the
2302 // overlay: the preload tears down on the captured signal, and
2303 // by now it may be running a newer pick.
2304 if (!claimSettle()) return Effect.void;
2305 return attempt(
2306 { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id },
2307 () => {
2308 if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL);
2309 },
2310 ).pipe(Effect.ignore, Effect.andThen(finishPick(result)));
2311 }),
2312 ),
2313 );
2314 };
2315 const onDestroyed = () => settle(null);
2316 const onNavigated = (
2317 event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
2318 ) => {
2319 if (event.isMainFrame) settle(null);
2320 };
2321 const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () {
2322 // Two picks on one tab can overlap. Swap this session in and cancel
2323 // the previous holder in one step, so no third pick can slip into an
2324 // empty slot in between and the session we push out still resumes
2325 // its renderer.
2326 const replaced = yield* Ref.modify(pickSessionsRef, (sessions) => [
2327 sessions.get(tabId) ?? null,
2328 replaceMap(sessions, (copy) => {
2329 copy.set(tabId, session);
2330 }),
2331 ]);
2332 if (replaced) yield* replaced.cancel;
2333 // A newer pick may have cancelled this session while the previous
2334 // one was torn down. Cleanup already ran, so attaching listeners now
2335 // would leak them and start an overlay nobody is waiting on.
2336 if (settled) return;
2337 yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => {
2338 wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage);
2339 wc.once("destroyed", onDestroyed);
2340 wc.on("did-start-navigation", onNavigated);
2341 if (!wc.isFocused()) wc.focus();
2342 wc.send(START_PICK_CHANNEL, annotationTheme, annotationSendEnabled.get(tabId) === true);
2343 });
2344 });
2345 runFork(
2346 registerPickElement().pipe(
2347 Effect.catch((error: PreviewManagerError) => {
2348 if (!claimSettle()) return Effect.void;
2349 resume(Effect.fail(error));
2350 return cleanup();
2351 }),
2352 ),
2353 );
2354 return session.cancel;
2355 },
2356 );
2357 });
2358
2359 /**
2360 * Chromium hands every guest `<webview>` the embedder's zoom level, so zooming
2361 * the app UI drags the previewed page along with it. The preview browser owns
2362 * its own zoom factor, so re-assert it on each attached guest whenever the main
2363 * window's zoom changes (see DesktopWindow.zoomMain).
2364 */
2365 const reapplyZoom = Effect.fn("PreviewManager.reapplyZoom")(function* () {
2366 const tabIds = Array.from((yield* SynchronizedRef.get(tabsRef)).keys());
2367 yield* Effect.forEach(tabIds, assertTabZoom, { discard: true });
2368 });
2369
2370 const applyZoom = Effect.fn("PreviewManager.applyZoom")(function* (
2371 tabId: string,
2372 transform: (current: number) => number,
2373 ) {
2374 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
2375 if (!tab) return;
2376 const next = transform(tab.zoomFactor);
2377 if (Math.abs(next - tab.zoomFactor) < ZOOM_EPSILON) return;
2378 if (tab.webContentsId != null) {
2379 const wc = webContents.fromId(tab.webContentsId);
2380 if (wc && !wc.isDestroyed()) {
2381 yield* attempt({ operation: "applyZoom", tabId, webContentsId: wc.id }, () =>
2382 wc.setZoomFactor(next),
2383 );
2384 }
2385 }
2386 yield* update(tabId, { zoomFactor: next });
2387 });
2388
2389 // Emulated media lives on the CDP debugger session, not the WebContents, so
2390 // it is lost whenever the session detaches (webview swap, DevTools
2391 // open/close) and must be re-applied after every (re)attach.
2392 const applyColorScheme = Effect.fn("PreviewManager.applyColorScheme")(function* (
2393 tabId: string,
2394 wc: Electron.WebContents,
2395 colorScheme: DesktopPreviewColorScheme,
2396 ) {
2397 const control = yield* ensureControlSession(wc);
2398 yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () =>
2399 control.debugger.sendCommand("Emulation.setEmulatedMedia", {
2400 features: [
2401 {
2402 name: "prefers-color-scheme",
2403 // An empty value clears the override so the page follows the OS.
2404 value: colorScheme === "system" ? "" : colorScheme,
2405 },
2406 ],
2407 }),
2408 );
2409 });
2410
2411 // Re-establish the control session after a detach, restoring any
2412 // color-scheme override the tab carries. The scheme is read after the
2413 // session attaches so a concurrent setColorScheme is not overwritten with
2414 // a stale snapshot.
2415 const restoreControlSession = (tabId: string, wc: Electron.WebContents) =>
2416 Effect.gen(function* () {
2417 const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
2418 if (beforeAttach?.webContentsId !== wc.id) return;
2419 const control = yield* ensureControlSession(wc);
2420 const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
2421 if (afterAttach?.webContentsId !== wc.id) {
2422 yield* detachControlSession(wc.id);
2423 return;
2424 }
2425 if (afterAttach.serverTab) {
2426 yield* listenForAgentPointers;
2427 browserHost.attach(afterAttach.serverTab, { webContents: wc, debugger: control.debugger });
2428 }
2429 if (afterAttach.colorScheme !== "system") {
2430 yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () =>
2431 control.debugger.sendCommand("Emulation.setEmulatedMedia", {
2432 features: [
2433 {
2434 name: "prefers-color-scheme",
2435 value: afterAttach.colorScheme,
2436 },
2437 ],
2438 }),
2439 );
2440 }
2441 }).pipe(Effect.ignore);
2442
2443 const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* (
2444 tabId: string,
2445 colorScheme: DesktopPreviewColorScheme,
2446 ) {
2447 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
2448 if (!tab) {
2449 return yield* new PreviewTabNotFoundError({ tabId });
2450 }
2451 if (tab.colorScheme !== colorScheme) {
2452 // Record the choice even when the CDP call below can't run yet (no
2453 // webview, DevTools holding the debugger) — it is re-applied on the
2454 // next control-session (re)attach.
2455 yield* update(tabId, { colorScheme });
2456 }
2457 // Re-read after the update: registerWebview may have swapped the guest
2458 // in the meantime and the override must land on the current one.
2459 const webContentsId = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.webContentsId;
2460 if (webContentsId == null) return;
2461 const wc = webContents.fromId(webContentsId);
2462 if (!wc || wc.isDestroyed()) return;
2463 yield* applyColorScheme(tabId, wc, colorScheme);
2464 });
2465
2466 const setAudioMuted = Effect.fn("PreviewManager.setAudioMuted")(function* (
2467 tabId: string,
2468 audioMuted: boolean,
2469 ) {
2470 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
2471 if (!tab) {
2472 return yield* new PreviewTabNotFoundError({ tabId });
2473 }
2474 // Commit and apply under the tab's lifecycle lock, then assert the
2475 // committed value rather than this call's argument. Two overlapping toggles
2476 // would otherwise be free to commit in one order and reach Chromium in the
2477 // other, leaving the icon disagreeing with the guest.
2478 yield* withTabLifecycleLock(
2479 tabId,
2480 Effect.gen(function* () {
2481 // Record the intent even when no guest is attached yet — it is
2482 // re-applied by registerWebview when one arrives.
2483 const previous = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.audioMuted;
2484 const committed = previous !== undefined && previous !== audioMuted;
2485 if (committed) {
2486 yield* update(tabId, { audioMuted });
2487 }
2488 // Roll the commit back if Chromium refused: reporting success here
2489 // would leave the tab drawn as muted while it keeps playing.
2490 yield* assertTabAudioMuted(tabId).pipe(
2491 Effect.tapError(() =>
2492 committed ? update(tabId, { audioMuted: previous }) : Effect.void,
2493 ),
2494 );
2495 }),
2496 );
2497 });
2498
2499 const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* (
2500 tabId: string,
2501 ) {
2502 const wc = yield* requireWebContents(tabId);
2503 const [createdAt, millis, image] = yield* Effect.all([
2504 currentIso,
2505 currentMillis,
2506 capturePageWithRetry(
2507 {
2508 operation: "captureScreenshot.capturePage",
2509 tabId,
2510 webContentsId: wc.id,
2511 },
2512 tabId,
2513 wc,
2514 ),
2515 ]);
2516 const uuid = yield* crypto.randomUUIDv4().pipe(Effect.orDie);
2517 const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}-${uuid.slice(0, 8)}`;
2518 const artifactPath = path.join(resolvedArtifactDirectory, `${id}.png`);
2519 const data = image.toPNG();
2520 yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe(
2521 Effect.mapError(
2522 (cause) =>
2523 new PreviewOperationError({
2524 operation: "captureScreenshot.makeDirectory",
2525 tabId,
2526 webContentsId: wc.id,
2527 artifactPath,
2528 cause,
2529 }),
2530 ),
2531 );
2532 yield* fileSystem.writeFile(artifactPath, data).pipe(
2533 Effect.mapError(
2534 (cause) =>
2535 new PreviewOperationError({
2536 operation: "captureScreenshot.writeFile",
2537 tabId,
2538 webContentsId: wc.id,
2539 artifactPath,
2540 cause,
2541 }),
2542 ),
2543 );
2544 return {
2545 id,
2546 tabId,
2547 path: artifactPath,
2548 mimeType: "image/png" as const,
2549 sizeBytes: data.byteLength,
2550 createdAt,
2551 };
2552 });
2553
2554 const capturePreviewFrame = Effect.fn("PreviewManager.capturePreviewFrame")(function* (
2555 tabId: string,
2556 ) {
2557 const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId);
2558 if (!captureSession?.consumers.has("picture-in-picture") || captureSession.scope === null)
2559 return;
2560 const wc = yield* requireWebContents(tabId);
2561 const image = yield* attemptPromise(
2562 {
2563 operation: "frameCapture.capturePage",
2564 tabId,
2565 webContentsId: wc.id,
2566 },
2567 () => wc.capturePage(),
2568 );
2569 const currentCaptureSession = yield* Effect.all(
2570 [SynchronizedRef.get(frameCaptureSessionsRef), SynchronizedRef.get(tabsRef)],
2571 { concurrency: 2 },
2572 ).pipe(
2573 Effect.map(([captureSessions, tabs]) => {
2574 const current = captureSessions.get(tabId);
2575 return current?.scope === captureSession.scope &&
2576 tabs.get(tabId)?.webContentsId === wc.id &&
2577 !wc.isDestroyed()
2578 ? current
2579 : undefined;
2580 }),
2581 );
2582 if (!currentCaptureSession) return;
2583 const size = yield* attempt(
2584 {
2585 operation: "frameCapture.measureFrame",
2586 tabId,
2587 webContentsId: wc.id,
2588 },
2589 () => image.getSize(),
2590 );
2591 if (
2592 !Number.isFinite(size.width) ||
2593 !Number.isFinite(size.height) ||
2594 size.width <= 0 ||
2595 size.height <= 0
2596 ) {
2597 return;
2598 }
2599 const encoded = yield* attempt(
2600 {
2601 operation: "frameCapture.encodeFrame",
2602 tabId,
2603 webContentsId: wc.id,
2604 },
2605 () => image.toJPEG(PICTURE_IN_PICTURE_JPEG_QUALITY),
2606 );
2607 const frameSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId);
2608 if (frameSession?.scope !== captureSession.scope) return;
2609 const pictureInPicture =
2610 frameSession.consumers.has("picture-in-picture") &&
2611 frameSession.lastPictureInPictureFrame?.equals(encoded) !== true;
2612 if (!pictureInPicture) return;
2613 const receivedAt = yield* currentIso;
2614 const frame: DesktopPreviewRecordingFrame = {
2615 tabId,
2616 data: encoded.toString("base64"),
2617 width: size.width,
2618 height: size.height,
2619 receivedAt,
2620 };
2621 const deliveries: Array<Effect.Effect<void>> = [];
2622 if (pictureInPicture) {
2623 const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(
2624 tabId,
2625 )?.window;
2626 if (pictureInPictureWindow && !pictureInPictureWindow.isDestroyed()) {
2627 deliveries.push(
2628 Effect.gen(function* () {
2629 const previousAspectRatio = (yield* Ref.get(pictureInPictureAspectRatiosRef)).get(
2630 tabId,
2631 );
2632 const aspectRatio = frame.width / frame.height;
2633 if (
2634 previousAspectRatio === undefined ||
2635 Math.abs(previousAspectRatio - aspectRatio) > PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON
2636 ) {
2637 yield* attempt(
2638 {
2639 operation: "pictureInPicture.setAspectRatio",
2640 tabId,
2641 webContentsId: wc.id,
2642 },
2643 () => {
2644 const contentSize = fitPictureInPictureContentSize(
2645 pictureInPictureWindow.getContentSize(),
2646 aspectRatio,
2647 );
2648 pictureInPictureWindow.setAspectRatio(0);
2649 pictureInPictureWindow.setContentSize(contentSize[0], contentSize[1], false);
2650 pictureInPictureWindow.setAspectRatio(aspectRatio);
2651 },
2652 );
2653 yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) =>
2654 replaceMap(aspectRatios, (copy) => {
2655 copy.set(tabId, aspectRatio);
2656 }),
2657 );
2658 }
2659 yield* attempt(
2660 {
2661 operation: "pictureInPicture.deliverFrame",
2662 tabId,
2663 webContentsId: wc.id,
2664 },
2665 () => {
2666 pictureInPictureWindow.webContents.send(
2667 PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL,
2668 frame,
2669 );
2670 },
2671 );
2672 yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => {
2673 if (sessions.get(tabId) !== frameSession) return sessions;
2674 return replaceMap(sessions, (copy) => {
2675 copy.set(tabId, {
2676 ...frameSession,
2677 lastPictureInPictureFrame: encoded,
2678 });
2679 });
2680 });
2681 }).pipe(
2682 Effect.catch((error) =>
2683 Effect.logWarning("Picture-in-picture frame delivery failed.", {
2684 tabId,
2685 error,
2686 }),
2687 ),
2688 ),
2689 );
2690 }
2691 }
2692 yield* Effect.all(deliveries, { concurrency: 2, discard: true });
2693 });
2694
2695 const startFrameCapture = Effect.fn("PreviewManager.startFrameCapture")(function* (
2696 tabId: string,
2697 consumer: FrameCaptureConsumer,
2698 ) {
2699 // Recording keeps only the activity lease. Picture-in-picture owns the
2700 // capturePage loop and tolerates transient compositor warmup failures.
2701 const captureNextFrame = Effect.sleep(PICTURE_IN_PICTURE_FRAME_INTERVAL_MS).pipe(
2702 Effect.andThen(capturePreviewFrame(tabId)),
2703 Effect.catch((error) =>
2704 Effect.logWarning("Background preview frame capture failed.", {
2705 tabId,
2706 error,
2707 }),
2708 ),
2709 );
2710 const captureInitialFrame = yield* SynchronizedRef.modifyEffect(
2711 frameCaptureSessionsRef,
2712 (sessions) => {
2713 return Effect.gen(function* () {
2714 if (!frameCaptureWindowOpen) {
2715 return yield* new PreviewMainWindowClosedError({ tabId });
2716 }
2717 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
2718 if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) {
2719 return yield* new PreviewTabNotFoundError({ tabId });
2720 }
2721 const wc = yield* requireWebContents(tabId);
2722 const current = sessions.get(tabId);
2723 if (current) {
2724 if (current.consumers.has(consumer)) {
2725 return [false, sessions] as const;
2726 }
2727 if (!current.unthrottledWebContentsIds.has(wc.id)) {
2728 yield* setFrameCaptureWebContentsBackgroundThrottling(wc, false);
2729 }
2730 let scope = current.scope;
2731 if (consumer === "picture-in-picture" && scope === null) {
2732 scope = yield* Scope.fork(parentScope, "sequential");
2733 yield* Effect.forkIn(Effect.forever(captureNextFrame), scope);
2734 }
2735 return [
2736 consumer === "picture-in-picture",
2737 replaceMap(sessions, (copy) => {
2738 copy.set(tabId, {
2739 ...current,
2740 scope,
2741 consumers: new Set([...current.consumers, consumer]),
2742 unthrottledWebContentsIds: new Set([...current.unthrottledWebContentsIds, wc.id]),
2743 });
2744 }),
2745 ] as const;
2746 }
2747 if (sessions.size === 0) {
2748 yield* setFrameCaptureBackgroundThrottling(false);
2749 }
2750 yield* setFrameCaptureWebContentsBackgroundThrottling(wc, false).pipe(
2751 Effect.onError(() =>
2752 sessions.size === 0
2753 ? setFrameCaptureBackgroundThrottling(true).pipe(Effect.ignore)
2754 : Effect.void,
2755 ),
2756 );
2757 const scope =
2758 consumer === "picture-in-picture" ? yield* Scope.fork(parentScope, "sequential") : null;
2759 if (scope !== null) {
2760 yield* Effect.forkIn(Effect.forever(captureNextFrame), scope);
2761 }
2762 return [
2763 consumer === "picture-in-picture",
2764 replaceMap(sessions, (copy) => {
2765 copy.set(tabId, {
2766 scope,
2767 consumers: new Set([consumer]),
2768 unthrottledWebContentsIds: new Set([wc.id]),
2769 lastPictureInPictureFrame: null,
2770 });
2771 }),
2772 ] as const;
2773 });
2774 },
2775 ).pipe(Effect.uninterruptible);
2776 if (!captureInitialFrame) return;
2777 yield* capturePreviewFrame(tabId).pipe(
2778 Effect.catch((error) =>
2779 Effect.logWarning("Initial background preview frame was not ready; capture will retry.", {
2780 tabId,
2781 consumer,
2782 error,
2783 }),
2784 ),
2785 );
2786 });
2787
2788 const releasePictureInPicture = Effect.fn("PreviewManager.releasePictureInPicture")(function* (
2789 tabId: string,
2790 expectedSession: PictureInPictureSession,
2791 closeWindow: boolean,
2792 ) {
2793 const removed = yield* SynchronizedRef.modify(pictureInPictureSessionsRef, (sessions) => {
2794 if (sessions.get(tabId) !== expectedSession) {
2795 return [false, sessions] as const;
2796 }
2797 return [
2798 true,
2799 replaceMap(sessions, (copy) => {
2800 copy.delete(tabId);
2801 }),
2802 ] as const;
2803 });
2804 if (!removed) return;
2805 yield* Deferred.interrupt(expectedSession.ready);
2806 yield* Scope.close(expectedSession.initializationScope, Exit.void).pipe(Effect.ignore);
2807 yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) =>
2808 replaceMap(aspectRatios, (copy) => {
2809 copy.delete(tabId);
2810 }),
2811 );
2812 yield* stopFrameCapture(tabId, "picture-in-picture");
2813 const tabs = yield* SynchronizedRef.get(tabsRef);
2814 if (tabs.has(tabId)) {
2815 yield* update(tabId, { pictureInPicture: false });
2816 }
2817 if (closeWindow && !expectedSession.window.isDestroyed()) {
2818 yield* attempt({ operation: "pictureInPicture.close", tabId }, () =>
2819 expectedSession.window.close(),
2820 ).pipe(Effect.ignore);
2821 }
2822 });
2823
2824 const closePictureInPictureUnlocked = Effect.fn("PreviewManager.closePictureInPictureUnlocked")(
2825 function* (tabId: string) {
2826 const pictureInPictureSession = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(
2827 tabId,
2828 );
2829 if (!pictureInPictureSession) {
2830 yield* stopFrameCapture(tabId, "picture-in-picture");
2831 const tabs = yield* SynchronizedRef.get(tabsRef);
2832 if (tabs.has(tabId)) {
2833 yield* update(tabId, { pictureInPicture: false });
2834 }
2835 return;
2836 }
2837 yield* releasePictureInPicture(tabId, pictureInPictureSession, true);
2838 },
2839 );
2840
2841 const closePictureInPicture = Effect.fn("PreviewManager.closePictureInPicture")(function* (
2842 tabId: string,
2843 ) {
2844 yield* pictureInPictureMutationSemaphore.withPermit(closePictureInPictureUnlocked(tabId));
2845 });
2846
2847 const closeAllPictureInPicture = Effect.fn("PreviewManager.closeAllPictureInPicture")(
2848 function* () {
2849 const sessions = yield* SynchronizedRef.get(pictureInPictureSessionsRef);
2850 yield* Effect.forEach(sessions.keys(), closePictureInPicture, {
2851 concurrency: "unbounded",
2852 discard: true,
2853 });
2854 },
2855 );
2856
2857 const openPictureInPicture = Effect.fn("PreviewManager.openPictureInPicture")(function* (
2858 tabId: string,
2859 ) {
2860 const claim = yield* pictureInPictureMutationSemaphore.withPermit(
2861 Effect.gen(function* () {
2862 const existing = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId);
2863 if (existing && !existing.window.isDestroyed()) {
2864 return { kind: "existing" as const, session: existing };
2865 }
2866 if (existing) {
2867 yield* releasePictureInPicture(tabId, existing, false);
2868 }
2869 const wc = yield* requireWebContents(tabId);
2870 const title = yield* attempt(
2871 {
2872 operation: "pictureInPicture.readTitle",
2873 tabId,
2874 webContentsId: wc.id,
2875 },
2876 () => wc.getTitle().trim(),
2877 );
2878 const pictureInPictureWindow = yield* attempt(
2879 {
2880 operation: "pictureInPicture.create",
2881 tabId,
2882 webContentsId: wc.id,
2883 },
2884 () =>
2885 new BrowserWindow({
2886 width: PICTURE_IN_PICTURE_INITIAL_WIDTH,
2887 height: PICTURE_IN_PICTURE_INITIAL_HEIGHT,
2888 minWidth: PICTURE_IN_PICTURE_MIN_WIDTH,
2889 minHeight: PICTURE_IN_PICTURE_MIN_HEIGHT,
2890 title: title.length > 0 ? `Preview · ${title}` : "Browser preview",
2891 show: false,
2892 alwaysOnTop: true,
2893 autoHideMenuBar: true,
2894 fullscreenable: false,
2895 maximizable: false,
2896 minimizable: false,
2897 resizable: true,
2898 skipTaskbar: true,
2899 backgroundColor: "#111111",
2900 ...(hostPlatform === "darwin" ? { type: "panel" as const } : {}),
2901 webPreferences: {
2902 preload: pictureInPicturePreloadPath,
2903 backgroundThrottling: false,
2904 contextIsolation: true,
2905 nodeIntegration: false,
2906 sandbox: true,
2907 },
2908 }),
2909 );
2910 yield* rendererHistory.register(pictureInPictureWindow.webContents, {
2911 surface: "picture-in-picture",
2912 tabId,
2913 });
2914 const initializationScope = yield* Scope.fork(parentScope, "sequential");
2915 const ready = yield* Deferred.make<void, PreviewManagerError>();
2916 const session: PictureInPictureSession = {
2917 window: pictureInPictureWindow,
2918 webContentsId: wc.id,
2919 ready,
2920 initializationScope,
2921 };
2922 const onClosed = () => {
2923 runFork(
2924 pictureInPictureMutationSemaphore.withPermit(
2925 releasePictureInPicture(tabId, session, false),
2926 ),
2927 );
2928 };
2929 const onDidFinishLoad = () => {
2930 runFork(
2931 SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => {
2932 const current = sessions.get(tabId);
2933 if (!current?.consumers.has("picture-in-picture")) return sessions;
2934 return replaceMap(sessions, (copy) => {
2935 copy.set(tabId, { ...current, lastPictureInPictureFrame: null });
2936 });
2937 }),
2938 );
2939 };
2940 const pipWebContents = pictureInPictureWindow.webContents;
2941 yield* attempt(
2942 {
2943 operation: "pictureInPicture.configure",
2944 tabId,
2945 webContentsId: wc.id,
2946 },
2947 () => {
2948 pictureInPictureWindow.once("closed", onClosed);
2949 pictureInPictureWindow.setAlwaysOnTop(
2950 true,
2951 hostPlatform === "darwin" ? "floating" : "normal",
2952 );
2953 if (hostPlatform === "darwin") {
2954 pictureInPictureWindow.setVisibleOnAllWorkspaces(true, {
2955 visibleOnFullScreen: true,
2956 // Electron otherwise temporarily transforms the entire app into
2957 // a UIElement process, which removes the owning app from the Dock.
2958 skipTransformProcessType: true,
2959 });
2960 }
2961 pipWebContents.on("did-finish-load", onDidFinishLoad);
2962 },
2963 ).pipe(
2964 Effect.onError(() =>
2965 Effect.all(
2966 [
2967 Scope.close(initializationScope, Exit.void).pipe(Effect.ignore),
2968 attempt({ operation: "pictureInPicture.close", tabId }, () =>
2969 pictureInPictureWindow.close(),
2970 ).pipe(Effect.ignore),
2971 ],
2972 { discard: true },
2973 ),
2974 ),
2975 );
2976 yield* Scope.addFinalizer(
2977 initializationScope,
2978 Effect.sync(() => {
2979 pipWebContents.off("did-finish-load", onDidFinishLoad);
2980 }).pipe(Effect.ignore),
2981 );
2982 yield* SynchronizedRef.update(pictureInPictureSessionsRef, (sessions) =>
2983 replaceMap(sessions, (copy) => {
2984 copy.set(tabId, session);
2985 }),
2986 );
2987 return { kind: "created" as const, session };
2988 }),
2989 );
2990 const pictureInPictureSession = claim.session;
2991 if (claim.kind === "existing") {
2992 yield* Deferred.await(pictureInPictureSession.ready);
2993 return yield* pictureInPictureMutationSemaphore.withPermit(
2994 Effect.gen(function* () {
2995 const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId);
2996 if (current !== pictureInPictureSession || pictureInPictureSession.window.isDestroyed()) {
2997 return yield* new PreviewOperationError({
2998 operation: "pictureInPicture.showExisting",
2999 tabId,
3000 webContentsId: pictureInPictureSession.webContentsId,
3001 cause: new Error("Picture-in-picture session closed before it became visible."),
3002 });
3003 }
3004 yield* attempt(
3005 {
3006 operation: "pictureInPicture.showExisting",
3007 tabId,
3008 webContentsId: pictureInPictureSession.webContentsId,
3009 },
3010 () => pictureInPictureSession.window.showInactive(),
3011 );
3012 }),
3013 );
3014 }
3015
3016 const initialize = Effect.gen(function* () {
3017 yield* attemptPromise(
3018 {
3019 operation: "pictureInPicture.load",
3020 tabId,
3021 webContentsId: pictureInPictureSession.webContentsId,
3022 },
3023 () => pictureInPictureSession.window.loadURL(buildPreviewPictureInPictureDataUrl()),
3024 );
3025 const currentWebContents = yield* requireWebContents(tabId);
3026 if (
3027 currentWebContents.id !== pictureInPictureSession.webContentsId ||
3028 currentWebContents.isDestroyed()
3029 ) {
3030 return yield* new PreviewOperationError({
3031 operation: "pictureInPicture.validateWebContents",
3032 tabId,
3033 webContentsId: pictureInPictureSession.webContentsId,
3034 cause: new Error("Preview webview changed while picture-in-picture was opening."),
3035 });
3036 }
3037 yield* startFrameCapture(tabId, "picture-in-picture");
3038 yield* attempt(
3039 {
3040 operation: "pictureInPicture.show",
3041 tabId,
3042 webContentsId: pictureInPictureSession.webContentsId,
3043 },
3044 () => pictureInPictureSession.window.showInactive(),
3045 );
3046 });
3047 const initializationExit = yield* Effect.gen(function* () {
3048 const initializationFiber = yield* Effect.forkIn(
3049 initialize,
3050 pictureInPictureSession.initializationScope,
3051 );
3052 return yield* Fiber.await(initializationFiber);
3053 }).pipe(
3054 Effect.onInterrupt(() =>
3055 pictureInPictureMutationSemaphore.withPermit(
3056 releasePictureInPicture(tabId, pictureInPictureSession, true),
3057 ),
3058 ),
3059 );
3060 if (Exit.isSuccess(initializationExit)) {
3061 const published = yield* pictureInPictureMutationSemaphore.withPermit(
3062 Effect.gen(function* () {
3063 const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId);
3064 if (current !== pictureInPictureSession || pictureInPictureSession.window.isDestroyed()) {
3065 if (current === pictureInPictureSession) {
3066 yield* releasePictureInPicture(tabId, pictureInPictureSession, false);
3067 }
3068 return false;
3069 }
3070 yield* update(tabId, { pictureInPicture: true });
3071 yield* Deferred.done(pictureInPictureSession.ready, initializationExit);
3072 return true;
3073 }),
3074 );
3075 if (published) return;
3076 return yield* Deferred.await(pictureInPictureSession.ready);
3077 }
3078 yield* Deferred.done(pictureInPictureSession.ready, initializationExit);
3079 const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId);
3080 if (current === pictureInPictureSession) {
3081 yield* pictureInPictureMutationSemaphore.withPermit(
3082 releasePictureInPicture(tabId, pictureInPictureSession, true),
3083 );
3084 }
3085 return yield* Effect.failCause(initializationExit.cause);
3086 });
3087
3088 /** Only drops the armed target when it still belongs to `tabId`, so tabs cannot clobber each other. */
3089 const clearPendingRecording = (tabId: string) => {
3090 if (pendingRecording?.tabId === tabId) pendingRecording = null;
3091 };
3092
3093 /**
3094 * Claims the single arm slot for `tabId`. A display-media request carries no tab identity, so the
3095 * slot is exclusive: a second tab arming before the first request lands would redirect the first
3096 * renderer's stream. Rather than queue (which can only ever stall a start), a colliding start
3097 * fails fast and the renderer can retry. An arm the renderer never redeemed goes stale after
3098 * `RECORDING_ARM_GRACE_MS` so it cannot hold the slot forever.
3099 */
3100 const armPendingRecording = Effect.fn("PreviewManager.armPendingRecording")(function* (
3101 tabId: string,
3102 wc: Electron.WebContents,
3103 requestingFrameTreeNodeId: number,
3104 ) {
3105 const now = yield* Clock.currentTimeMillis;
3106 const previous = pendingRecording;
3107 if (
3108 previous !== null &&
3109 previous.tabId !== tabId &&
3110 !previous.webContents.isDestroyed() &&
3111 now - previous.armedAtMillis < RECORDING_ARM_GRACE_MS
3112 ) {
3113 return yield* new PreviewRecordingArmConflictError({
3114 tabId,
3115 webContentsId: wc.id,
3116 armedTabId: previous.tabId,
3117 });
3118 }
3119 const armed: PendingRecording = {
3120 tabId,
3121 webContents: wc,
3122 requestingFrameTreeNodeId,
3123 armedAtMillis: now,
3124 };
3125 pendingRecording = armed;
3126 // The handler callback is sync and cannot read a clock, so expiry is driven from here.
3127 // Identity compare: a re-arm replaces the object, and this fiber must not clobber it.
3128 yield* Effect.forkIn(
3129 Effect.sleep(RECORDING_ARM_GRACE_MS).pipe(
3130 Effect.andThen(
3131 Effect.sync(() => {
3132 if (pendingRecording === armed) pendingRecording = null;
3133 }),
3134 ),
3135 ),
3136 parentScope,
3137 );
3138 });
3139
3140 // Installed once per session: answers the renderer's `getDisplayMedia()` with the tab that
3141 // `startRecording` armed, and denies anything else so pages cannot capture on their own.
3142 const installDisplayMediaRequestHandler = (session: Session) => {
3143 if (displayMediaHandlerSessions.has(session)) return;
3144 displayMediaHandlerSessions.add(session);
3145 session.setDisplayMediaRequestHandler((request, callback) => {
3146 const armed = pendingRecording;
3147 if (!armed) {
3148 callback({});
3149 return;
3150 }
3151 if (armed.webContents.isDestroyed()) {
3152 pendingRecording = null;
3153 callback({});
3154 return;
3155 }
3156 if (request.frame?.frameTreeNodeId !== armed.requestingFrameTreeNodeId) {
3157 callback({});
3158 return;
3159 }
3160 pendingRecording = null;
3161 callback({ video: armed.webContents.mainFrame });
3162 });
3163 };
3164
3165 const startRecording = Effect.fn("PreviewManager.startRecording")(function* (
3166 tabId: string,
3167 options: RecordingInputOptions = DEFAULT_RECORDING_INPUT_OPTIONS,
3168 ) {
3169 if ((yield* Ref.get(closingTabIdsRef)).has(tabId)) {
3170 return yield* new PreviewTabNotFoundError({ tabId });
3171 }
3172 return yield* withTabLifecycleLock(
3173 tabId,
3174 Effect.gen(function* () {
3175 yield* startFrameCapture(tabId, "recording");
3176 yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) =>
3177 replaceMap(sessions, (copy) => {
3178 const current = copy.get(tabId);
3179 if (current) copy.set(tabId, { ...current, recordingInputOptions: options });
3180 }),
3181 );
3182 const wc = yield* requireWebContents(tabId);
3183 const requestWebContents = wc.hostWebContents;
3184 if (requestWebContents === null) {
3185 return yield* new PreviewMainWindowClosedError({ tabId });
3186 }
3187 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
3188 yield* attempt({ operation: "recording.cursor", tabId, webContentsId: wc.id }, () =>
3189 wc.send(RECORDING_CURSOR_CHANNEL, true, options, tab?.controller),
3190 );
3191 yield* attemptPromise(
3192 {
3193 operation: "recording.warmSource",
3194 tabId,
3195 webContentsId: wc.id,
3196 },
3197 () => wc.capturePage().then(() => undefined),
3198 ).pipe(Effect.retry({ times: 1 }), Effect.ignore);
3199 const currentWebContents = yield* requireWebContents(tabId);
3200 if (currentWebContents !== wc || wc.isDestroyed()) {
3201 return yield* new PreviewWebContentsNotFoundError({
3202 tabId,
3203 webContentsId: wc.id,
3204 });
3205 }
3206 if (!frameCaptureWindowOpen || requestWebContents.isDestroyed()) {
3207 return yield* new PreviewMainWindowClosedError({ tabId });
3208 }
3209 installDisplayMediaRequestHandler(requestWebContents.session);
3210 yield* armPendingRecording(tabId, wc, requestWebContents.mainFrame.frameTreeNodeId);
3211 const captureRequested = yield* attemptPromise(
3212 {
3213 operation: "recording.requestCapture",
3214 tabId,
3215 webContentsId: requestWebContents.id,
3216 },
3217 () =>
3218 requestWebContents.executeJavaScript(requestRecordingCaptureExpression(tabId), true),
3219 );
3220 if (captureRequested !== true) {
3221 return yield* new PreviewRecordingCaptureUnavailableError({
3222 tabId,
3223 webContentsId: requestWebContents.id,
3224 });
3225 }
3226 }).pipe(
3227 Effect.onError(() => {
3228 clearPendingRecording(tabId);
3229 return stopFrameCapture(tabId, "recording").pipe(Effect.ignore);
3230 }),
3231 ),
3232 );
3233 });
3234
3235 const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) {
3236 // Clearing runs under the tab lock so it cannot land before an in-flight start arms.
3237 yield* withTabLifecycleLock(
3238 tabId,
3239 Effect.suspend(() => {
3240 clearPendingRecording(tabId);
3241 return stopFrameCapture(tabId, "recording");
3242 }),
3243 );
3244 });
3245
3246 const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* (
3247 tabId: string,
3248 mimeType: string,
3249 data: Uint8Array,
3250 ) {
3251 const [createdAt, millis] = yield* Effect.all([currentIso, currentMillis]);
3252 const id = `browser-recording-${millis.toString(36)}`;
3253 const extension = recordingFileExtension(mimeType);
3254 const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`);
3255 yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe(
3256 Effect.mapError(
3257 (cause) =>
3258 new PreviewOperationError({
3259 operation: "saveRecording.makeDirectory",
3260 tabId,
3261 artifactPath,
3262 cause,
3263 }),
3264 ),
3265 );
3266 yield* fileSystem.writeFile(artifactPath, data).pipe(
3267 Effect.mapError(
3268 (cause) =>
3269 new PreviewOperationError({
3270 operation: "saveRecording.writeFile",
3271 tabId,
3272 artifactPath,
3273 cause,
3274 }),
3275 ),
3276 );
3277 return {
3278 id,
3279 tabId,
3280 path: artifactPath,
3281 mimeType,
3282 sizeBytes: data.byteLength,
3283 createdAt,
3284 };
3285 });
3286
3287 const revealArtifact = Effect.fn("PreviewManager.revealArtifact")(function* (
3288 artifactPath: string,
3289 ) {
3290 const resolvedPath = yield* resolveArtifactPath(artifactPath);
3291 yield* attempt({ operation: "revealArtifact", artifactPath: resolvedPath }, () =>
3292 shell.showItemInFolder(resolvedPath),
3293 );
3294 });
3295
3296 const copyArtifactToClipboard = Effect.fn("PreviewManager.copyArtifactToClipboard")(function* (
3297 artifactPath: string,
3298 ) {
3299 const resolvedPath = yield* resolveArtifactPath(artifactPath);
3300 const image = yield* attempt(
3301 { operation: "copyArtifactToClipboard.load", artifactPath: resolvedPath },
3302 () => nativeImage.createFromPath(resolvedPath),
3303 );
3304 if (image.isEmpty()) {
3305 return yield* new PreviewArtifactImageLoadError({ artifactPath: resolvedPath });
3306 }
3307 yield* attemptPromise(
3308 { operation: "copyArtifactToClipboard.write", artifactPath: resolvedPath },
3309 () =>
3310 clipboard.write([
3311 new ClipboardItem({
3312 "image/png": new Blob([Uint8Array.from(image.toPNG())], { type: "image/png" }),
3313 }),
3314 ]),
3315 );
3316 });
3317
3318 let pointerSequence = 0;
3319 /**
3320 * The server reports where an agent action is about to land on a tab it
3321 * drives here. The live cursor and desktop recordings draw it like any agent.
3322 */
3323 const emitAgentPointer = Effect.fn("PreviewManager.emitAgentPointer")(function* (pointer: {
3324 readonly key: { readonly threadId: string; readonly tabId: string };
3325 readonly phase: "move" | "click";
3326 readonly x: number;
3327 readonly y: number;
3328 }) {
3329 const tab = [...(yield* SynchronizedRef.get(tabsRef)).values()].find(
3330 (candidate) =>
3331 candidate.serverTab?.threadId === pointer.key.threadId &&
3332 candidate.serverTab.tabId === pointer.key.tabId,
3333 );
3334 if (!tab) return;
3335 const event: DesktopPreviewPointerEvent = {
3336 tabId: tab.tabId,
3337 phase: pointer.phase,
3338 x: pointer.x,
3339 y: pointer.y,
3340 sequence: ++pointerSequence,
3341 createdAt: yield* currentIso,
3342 };
3343 const recording = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tab.tabId);
3344 const webContentsId = tab.webContentsId;
3345 if (recording?.consumers.has("recording") && webContentsId != null) {
3346 yield* attempt({ operation: "recording.pointer", tabId: tab.tabId }, () => {
3347 const contents = webContents.fromId(webContentsId);
3348 if (contents && !contents.isDestroyed()) contents.send(RECORDING_POINTER_CHANNEL, event);
3349 }).pipe(Effect.ignore);
3350 }
3351 const listeners = yield* Ref.get(pointerEventListenersRef);
3352 yield* Effect.forEach(
3353 listeners,
3354 (listener) => deliverEvent("pointer-event", tab.tabId, () => listener(event)),
3355 { discard: true },
3356 );
3357 });
3358 // Listening starts with the first tab of the desktop's own server.
3359 let pointersStarted = false;
3360 const listenForAgentPointers = Effect.suspend(() => {
3361 if (pointersStarted) return Effect.void;
3362 pointersStarted = true;
3363 return browserHost.pointers.pipe(
3364 Stream.runForEach(emitAgentPointer),
3365 Effect.forkIn(parentScope),
3366 Effect.asVoid,
3367 );
3368 });
3369
3370 const emitOpenLink = Effect.fn("PreviewManager.emitOpenLink")(function* (
3371 event: DesktopPreviewOpenLinkEvent,
3372 ) {
3373 const listeners = yield* Ref.get(openLinkListenersRef);
3374 yield* Effect.forEach(
3375 listeners,
3376 (listener) => deliverEvent("open-link", event.tabId, () => listener(event)),
3377 { discard: true },
3378 );
3379 });
3380 const subscribe = <A>(
3381 ref: Ref.Ref<ReadonlySet<A>>,
3382 listener: A,
3383 ): Effect.Effect<void, never, Scope.Scope> =>
3384 Effect.acquireRelease(
3385 Ref.update(ref, (listeners) => new Set([...listeners, listener])),
3386 () =>
3387 Ref.update(ref, (listeners) => {
3388 const next = new Set(listeners);
3389 next.delete(listener);
3390 return next;
3391 }),
3392 ).pipe(Effect.asVoid);
3393
3394 const destroy = Effect.fn("PreviewManager.destroy")(function* () {
3395 const tabs = yield* SynchronizedRef.get(tabsRef);
3396 yield* Effect.forEach(tabs.keys(), closeTab, { discard: true });
3397 yield* Effect.all(
3398 [
3399 Ref.set(listenersRef, new Set()),
3400 Ref.set(pointerEventListenersRef, new Set()),
3401 Ref.set(openLinkListenersRef, new Set()),
3402 Ref.set(recordingFrameListenersRef, new Set()),
3403 Ref.set(recordingInputListenersRef, new Set()),
3404 ],
3405 { discard: true },
3406 );
3407 });
3408
3409 yield* Effect.addFinalizer(() => destroy().pipe(Effect.ignore));
3410
3411 return {
3412 cancelPickElement,
3413 captureScreenshot,
3414 closeTab,
3415 copyArtifactToClipboard,
3416 createTab,
3417 goBack,
3418 goForward,
3419 hardReload,
3420 navigate,
3421 openPictureInPicture,
3422 openDevTools,
3423 pickElement,
3424 prepareWebview,
3425 ensureControlSession,
3426 reapplyZoom,
3427 refresh,
3428 registerWebview,
3429 resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR),
3430 setZoomFactor: (tabId: string, zoomFactor: number) =>
3431 applyZoom(tabId, () => normalizeZoomFactor(zoomFactor)),
3432 revealArtifact,
3433 saveRecording,
3434 setAnnotationTheme,
3435 setAnnotationSendEnabled,
3436 setAudioMuted,
3437 setColorScheme,
3438 setMainWindow,
3439 setForwardedShortcuts: (shortcuts: ReadonlyArray<PreviewForwardedShortcut>) =>
3440 Effect.sync(() => {
3441 forwardedShortcuts = shortcuts;
3442 }),
3443 startRecording,
3444 closePictureInPicture,
3445 stopRecording,
3446 subscribePointerEvents: (listener: PointerEventListener) =>
3447 subscribe(pointerEventListenersRef, listener),
3448 subscribeOpenLinks: (listener: OpenLinkListener) => subscribe(openLinkListenersRef, listener),
3449 subscribeRecordingInputs: (listener: RecordingInputListener) =>
3450 subscribe(recordingInputListenersRef, listener),
3451 subscribeRecordingFrames: (listener: RecordingFrameListener) =>
3452 subscribe(recordingFrameListenersRef, listener),
3453 subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener),
3454 zoomIn: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "in")),
3455 zoomOut: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "out")),
3456 };
3457});
3458
3459export class PreviewTabNotFoundError extends Schema.TaggedError<PreviewTabNotFoundError>()(
3460 "PreviewTabNotFoundError",
3461 { tabId: Schema.String },
3462) {
3463 override get message(): string {
3464 return `Preview tab not found: ${this.tabId}`;
3465 }
3466}
3467
3468export class PreviewWebContentsNotFoundError extends Schema.TaggedError<PreviewWebContentsNotFoundError>()(
3469 "PreviewWebContentsNotFoundError",
3470 { tabId: Schema.String, webContentsId: Schema.Number },
3471) {
3472 override get message(): string {
3473 return `WebContents ${this.webContentsId} not found for preview tab ${this.tabId}`;
3474 }
3475}
3476
3477export class PreviewWebviewNotInitializedError extends Schema.TaggedError<PreviewWebviewNotInitializedError>()(
3478 "PreviewWebviewNotInitializedError",
3479 { tabId: Schema.String },
3480) {
3481 override get message(): string {
3482 return `Preview tab "${this.tabId}" has no webview registered`;
3483 }
3484}
3485
3486export class PreviewMainWindowClosedError extends Schema.TaggedError<PreviewMainWindowClosedError>()(
3487 "PreviewMainWindowClosedError",
3488 { tabId: Schema.String },
3489) {
3490 override get message(): string {
3491 return `Cannot start preview frame capture while the main window is closed: ${this.tabId}`;
3492 }
3493}
3494
3495export class PreviewRecordingArmConflictError extends Schema.TaggedError<PreviewRecordingArmConflictError>()(
3496 "PreviewRecordingArmConflictError",
3497 {
3498 tabId: Schema.String,
3499 webContentsId: Schema.Number,
3500 armedTabId: Schema.String,
3501 },
3502) {
3503 override get message(): string {
3504 return `Preview tab ${this.armedTabId} is still claiming the capture stream, so recording could not start for tab ${this.tabId}`;
3505 }
3506}
3507
3508export class PreviewRecordingCaptureUnavailableError extends Schema.TaggedError<PreviewRecordingCaptureUnavailableError>()(
3509 "PreviewRecordingCaptureUnavailableError",
3510 {
3511 tabId: Schema.String,
3512 webContentsId: Schema.Number,
3513 },
3514) {
3515 override get message(): string {
3516 return `Preview recording capture is unavailable for tab ${this.tabId} in WebContents ${this.webContentsId}`;
3517 }
3518}
3519
3520export class PreviewOperationError extends Schema.TaggedError<PreviewOperationError>()(
3521 "PreviewOperationError",
3522 {
3523 operation: Schema.String,
3524 tabId: Schema.optional(Schema.String),
3525 webContentsId: Schema.optional(Schema.Number),
3526 artifactPath: Schema.optional(Schema.String),
3527 cause: Schema.Defect(),
3528 },
3529) {
3530 static toTimelineMessage(error: PreviewOperationError): string {
3531 return error.cause instanceof Error ? error.cause.message : String(error.cause);
3532 }
3533
3534 override get message(): string {
3535 const context = [
3536 this.tabId === undefined ? undefined : `tab ${this.tabId}`,
3537 this.webContentsId === undefined ? undefined : `WebContents ${this.webContentsId}`,
3538 this.artifactPath === undefined ? undefined : `artifact ${this.artifactPath}`,
3539 ].filter((value): value is string => value !== undefined);
3540 return `Desktop preview operation failed: ${this.operation}${context.length === 0 ? "" : ` (${context.join(", ")})`}`;
3541 }
3542}
3543
3544const isPreviewOperationError = Schema.is(PreviewOperationError);
3545
3546export class PreviewArtifactPathOutsideDirectoryError extends Schema.TaggedError<PreviewArtifactPathOutsideDirectoryError>()(
3547 "PreviewArtifactPathOutsideDirectoryError",
3548 {
3549 artifactPath: Schema.String,
3550 artifactDirectory: Schema.String,
3551 },
3552) {
3553 override get message(): string {
3554 return `Preview artifact path ${this.artifactPath} is outside ${this.artifactDirectory}`;
3555 }
3556}
3557
3558export class PreviewArtifactImageLoadError extends Schema.TaggedError<PreviewArtifactImageLoadError>()(
3559 "PreviewArtifactImageLoadError",
3560 { artifactPath: Schema.String },
3561) {
3562 override get message(): string {
3563 return `Preview artifact could not be loaded as an image: ${this.artifactPath}`;
3564 }
3565}
3566
3567export class PreviewAutomationDevToolsOpenError extends Schema.TaggedError<PreviewAutomationDevToolsOpenError>()(
3568 "PreviewAutomationDevToolsOpenError",
3569 { webContentsId: Schema.Number },
3570) {
3571 override get message(): string {
3572 return `Close preview DevTools before using agent browser control for WebContents ${this.webContentsId}`;
3573 }
3574}
3575
3576export class PreviewAutomationDebuggerAttachedError extends Schema.TaggedError<PreviewAutomationDebuggerAttachedError>()(
3577 "PreviewAutomationDebuggerAttachedError",
3578 { webContentsId: Schema.Number },
3579) {
3580 override get message(): string {
3581 return `Preview control cannot attach to WebContents ${this.webContentsId} because another debugger owns it`;
3582 }
3583}
3584
3585export const PreviewManagerError = Schema.Union([
3586 PreviewTabNotFoundError,
3587 PreviewWebContentsNotFoundError,
3588 PreviewWebviewNotInitializedError,
3589 PreviewMainWindowClosedError,
3590 PreviewRecordingArmConflictError,
3591 PreviewRecordingCaptureUnavailableError,
3592 PreviewOperationError,
3593 PreviewArtifactPathOutsideDirectoryError,
3594 PreviewArtifactImageLoadError,
3595 PreviewAutomationDevToolsOpenError,
3596 PreviewAutomationDebuggerAttachedError,
3597]);
3598export type PreviewManagerError = typeof PreviewManagerError.Type;
3599
3600export class PreviewManager extends Context.Service<
3601 PreviewManager,
3602 {
3603 readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>;
3604 readonly setForwardedShortcuts: (
3605 shortcuts: ReadonlyArray<PreviewForwardedShortcut>,
3606 ) => Effect.Effect<void>;
3607 readonly getBrowserSession: (
3608 scope?: string,
3609 persistent?: boolean,
3610 namespace?: BrowserSession.BrowserSessionPartitionNamespace,
3611 ) => Effect.Effect<Session, PreviewManagerError>;
3612 readonly isBrowserPartition: (partition: string) => boolean;
3613 readonly createTab: (
3614 tabId: string,
3615 defaults?: DesktopPreviewTabDefaults,
3616 ) => Effect.Effect<PreviewTabState, PreviewManagerError>;
3617 readonly closeTab: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3618 readonly registerWebview: (
3619 tabId: string,
3620 webContentsId: number,
3621 ) => Effect.Effect<void, PreviewManagerError>;
3622 readonly prepareWebview: (webContents: Electron.WebContents) => Effect.Effect<void>;
3623 readonly navigate: (tabId: string, url: string) => Effect.Effect<void, PreviewManagerError>;
3624 readonly goBack: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3625 readonly goForward: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3626 readonly refresh: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3627 readonly zoomIn: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3628 readonly zoomOut: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3629 readonly resetZoom: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3630 // Re-applies every attached guest's own zoom factor, undoing the zoom level
3631 // Chromium inherits from the embedder when the app UI zooms.
3632 readonly reapplyZoom: () => Effect.Effect<void>;
3633 readonly hardReload: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3634 readonly setColorScheme: (
3635 tabId: string,
3636 colorScheme: DesktopPreviewColorScheme,
3637 ) => Effect.Effect<void, PreviewManagerError>;
3638 readonly setZoomFactor: (
3639 tabId: string,
3640 zoomFactor: number,
3641 ) => Effect.Effect<void, PreviewManagerError>;
3642 readonly setAudioMuted: (
3643 tabId: string,
3644 audioMuted: boolean,
3645 ) => Effect.Effect<void, PreviewManagerError>;
3646 readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3647 readonly clearCookies: (
3648 partitions?: ReadonlyArray<string>,
3649 ) => Effect.Effect<void, PreviewManagerError>;
3650 readonly clearCache: (
3651 partitions?: ReadonlyArray<string>,
3652 ) => Effect.Effect<void, PreviewManagerError>;
3653 readonly getBrowserPartition: (
3654 scope?: string,
3655 persistent?: boolean,
3656 namespace?: BrowserSession.BrowserSessionPartitionNamespace,
3657 ) => Effect.Effect<string, PreviewManagerError>;
3658 readonly setAnnotationTheme: (
3659 theme: DesktopPreviewAnnotationTheme,
3660 ) => Effect.Effect<void, PreviewManagerError>;
3661 readonly setAnnotationSendEnabled: (
3662 tabId: string,
3663 enabled: boolean,
3664 ) => Effect.Effect<void, PreviewManagerError>;
3665 readonly pickElement: (
3666 tabId: string,
3667 ) => Effect.Effect<PreviewAnnotationSubmissionResult | null, PreviewManagerError>;
3668 readonly cancelPickElement: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3669 readonly captureScreenshot: (
3670 tabId: string,
3671 ) => Effect.Effect<DesktopPreviewScreenshotArtifact, PreviewManagerError>;
3672 readonly revealArtifact: (path: string) => Effect.Effect<void, PreviewManagerError>;
3673 readonly copyArtifactToClipboard: (path: string) => Effect.Effect<void, PreviewManagerError>;
3674 readonly openPictureInPicture: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3675 readonly closePictureInPicture: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3676 readonly startRecording: (
3677 tabId: string,
3678 options?: RecordingInputOptions,
3679 ) => Effect.Effect<void, PreviewManagerError>;
3680 readonly stopRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
3681 readonly saveRecording: (
3682 tabId: string,
3683 mimeType: string,
3684 data: Uint8Array,
3685 ) => Effect.Effect<DesktopPreviewRecordingArtifact, PreviewManagerError>;
3686 readonly subscribeStateChanges: (listener: Listener) => Effect.Effect<void, never, Scope.Scope>;
3687 readonly subscribePointerEvents: (
3688 listener: PointerEventListener,
3689 ) => Effect.Effect<void, never, Scope.Scope>;
3690 readonly subscribeOpenLinks: (
3691 listener: OpenLinkListener,
3692 ) => Effect.Effect<void, never, Scope.Scope>;
3693 readonly subscribeRecordingInputs: (
3694 listener: RecordingInputListener,
3695 ) => Effect.Effect<void, never, Scope.Scope>;
3696 readonly subscribeRecordingFrames: (
3697 listener: RecordingFrameListener,
3698 ) => Effect.Effect<void, never, Scope.Scope>;
3699 }
3700>()("@t3tools/desktop/preview/Manager/PreviewManager") {}
3701
3702/** @public Service construction is part of the canonical Effect module API. */
3703export const make = Effect.gen(function* PreviewManagerMake() {
3704 const environment = yield* DesktopEnvironment.DesktopEnvironment;
3705 const browserSession = yield* BrowserSession.BrowserSession;
3706 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
3707 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
3708 const preparedSessions = new WeakSet<Electron.Session>();
3709 const hostPlatform = yield* HostProcess.Platform;
3710 const hostArchitecture = yield* HostProcess.Architecture;
3711 const fileSystem = yield* FileSystem.FileSystem;
3712 const path = yield* Path.Path;
3713 const runFork = Effect.runForkWith(yield* Effect.context<never>());
3714 /** Puts a copy in Downloads under a name that never replaces a file already there. */
3715 const copyToDownloads = Effect.fn("PreviewManager.copyToDownloads")(function* (
3716 source: string,
3717 fileName: string,
3718 ) {
3719 const directory = app.getPath("downloads");
3720 const extension = path.extname(fileName);
3721 const stem = path.basename(fileName, extension) || "download";
3722 for (let attempt = 0; attempt < 100; attempt += 1) {
3723 const target = path.join(
3724 directory,
3725 attempt === 0 ? `${stem}${extension}` : `${stem} (${attempt})${extension}`,
3726 );
3727 if (yield* fileSystem.exists(target)) continue;
3728 yield* fileSystem.copyFile(source, target);
3729 shell.showItemInFolder(target);
3730 return;
3731 }
3732 });
3733 const prepareBrowserSession = (session: Electron.Session) => {
3734 if (preparedSessions.has(session)) return;
3735 const identity = browserChromeIdentity(session.getUserAgent(), {
3736 platform: hostPlatform,
3737 arch: hostArchitecture,
3738 chromeVersion: process.versions.chrome,
3739 });
3740 if (identity) {
3741 const metadata = identity.userAgentMetadata;
3742 const formatBrands = (brands: typeof metadata.brands) =>
3743 brands
3744 .map(({ brand, version }) => `${JSON.stringify(brand)};v=${JSON.stringify(version)}`)
3745 .join(", ");
3746 const lowEntropyHints = {
3747 "sec-ch-ua": formatBrands(metadata.brands),
3748 "sec-ch-ua-mobile": metadata.mobile ? "?1" : "?0",
3749 "sec-ch-ua-platform": JSON.stringify(metadata.platform),
3750 };
3751 const hints: Record<string, string> = {
3752 ...lowEntropyHints,
3753 "sec-ch-ua-full-version": JSON.stringify(metadata.fullVersionList[0]?.version),
3754 "sec-ch-ua-full-version-list": formatBrands(metadata.fullVersionList),
3755 "sec-ch-ua-platform-version": JSON.stringify(metadata.platformVersion),
3756 "sec-ch-ua-arch": JSON.stringify(metadata.architecture),
3757 "sec-ch-ua-bitness": JSON.stringify(metadata.bitness),
3758 "sec-ch-ua-model": JSON.stringify(metadata.model),
3759 "sec-ch-ua-wow64": metadata.wow64 ? "?1" : "?0",
3760 };
3761 session.webRequest.onBeforeSendHeaders(
3762 { urls: ["http://*/*", "https://*/*"] },
3763 (details, callback) => {
3764 const contents = details.webContents;
3765 const prepare =
3766 contents && (contents.isDestroyed() || !contents.isDevToolsOpened())
3767 ? operations.ensureControlSession(contents).pipe(Effect.asVoid)
3768 : Effect.void;
3769 runFork(
3770 prepare.pipe(
3771 Effect.matchCause({
3772 onFailure: () => callback({ cancel: true }),
3773 onSuccess: () => {
3774 const url = new URL(details.url);
3775 const sendHints =
3776 url.protocol === "https:" ||
3777 isLocalLoopbackHost(url.hostname) ||
3778 normalizeHostname(url.hostname).endsWith(".localhost");
3779 // Chromium can snapshot these headers before the asynchronous CDP override completes.
3780 const headers = Object.fromEntries(
3781 Object.entries(details.requestHeaders)
3782 .filter(([name]) => name.toLowerCase() !== "user-agent")
3783 .map(([name, value]) => [
3784 name,
3785 sendHints ? (hints[name.toLowerCase()] ?? value) : value,
3786 ]),
3787 );
3788 headers["User-Agent"] = identity.userAgent;
3789 if (sendHints) {
3790 for (const [name, value] of Object.entries(lowEntropyHints)) {
3791 const existing = Object.keys(headers).find(
3792 (key) => key.toLowerCase() === name,
3793 );
3794 headers[existing ?? name] = value;
3795 }
3796 }
3797 callback({ requestHeaders: headers });
3798 },
3799 }),
3800 ),
3801 );
3802 },
3803 );
3804 }
3805 session.on("will-download", (_event, item, source) => {
3806 // Decided before the server's path is set: placing it consumes the guid.
3807 const human = browserHost.humanStartedDownload(source);
3808 if (!browserHost.placeDownload(source, item) || !human) return;
3809 // The server keeps its copy for agents; the person who clicked gets theirs.
3810 item.once("done", (_doneEvent, state) => {
3811 if (state !== "completed") return;
3812 runFork(
3813 copyToDownloads(item.getSavePath(), item.getFilename()).pipe(
3814 Effect.catch((error) =>
3815 Effect.logWarning("preview download copy failed", { cause: error }),
3816 ),
3817 ),
3818 );
3819 });
3820 });
3821 preparedSessions.add(session);
3822 };
3823
3824 const operations = yield* makeNativeOperations(
3825 environment.browserArtifactsDir,
3826 environment.path.join(environment.dirname, "preview-pip-preload.cjs"),
3827 );
3828
3829 return PreviewManager.of({
3830 setMainWindow: operations.setMainWindow,
3831 setForwardedShortcuts: operations.setForwardedShortcuts,
3832 getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(
3833 function* (scope, persistent, namespace) {
3834 const session = yield* browserSession
3835 .getSession(scope, persistent, namespace)
3836 .pipe(
3837 Effect.mapError(
3838 (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }),
3839 ),
3840 );
3841 prepareBrowserSession(session);
3842 passkeys.installSessionHandlers(session);
3843 return session;
3844 },
3845 ),
3846 isBrowserPartition: browserSession.isPartition,
3847 createTab: operations.createTab,
3848 closeTab: operations.closeTab,
3849 registerWebview: operations.registerWebview,
3850 prepareWebview: operations.prepareWebview,
3851 navigate: operations.navigate,
3852 goBack: operations.goBack,
3853 goForward: operations.goForward,
3854 refresh: operations.refresh,
3855 zoomIn: operations.zoomIn,
3856 zoomOut: operations.zoomOut,
3857 resetZoom: operations.resetZoom,
3858 reapplyZoom: operations.reapplyZoom,
3859 hardReload: operations.hardReload,
3860 setColorScheme: operations.setColorScheme,
3861 setZoomFactor: operations.setZoomFactor,
3862 setAudioMuted: operations.setAudioMuted,
3863 openDevTools: operations.openDevTools,
3864 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* (partitions) {
3865 yield* browserSession
3866 .clearCookies(partitions)
3867 .pipe(
3868 Effect.mapError(
3869 (cause) => new PreviewOperationError({ operation: "clearCookies", cause }),
3870 ),
3871 );
3872 }),
3873 clearCache: Effect.fn("PreviewManager.clearCache")(function* (partitions) {
3874 yield* browserSession
3875 .clearCache(partitions)
3876 .pipe(
3877 Effect.mapError((cause) => new PreviewOperationError({ operation: "clearCache", cause })),
3878 );
3879 }),
3880 getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(
3881 function* (scope, persistent, namespace) {
3882 return yield* browserSession
3883 .getPartition(scope, persistent, namespace)
3884 .pipe(
3885 Effect.mapError(
3886 (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }),
3887 ),
3888 );
3889 },
3890 ),
3891 setAnnotationTheme: operations.setAnnotationTheme,
3892 setAnnotationSendEnabled: operations.setAnnotationSendEnabled,
3893 pickElement: operations.pickElement,
3894 cancelPickElement: operations.cancelPickElement,
3895 captureScreenshot: operations.captureScreenshot,
3896 revealArtifact: operations.revealArtifact,
3897 copyArtifactToClipboard: operations.copyArtifactToClipboard,
3898 openPictureInPicture: operations.openPictureInPicture,
3899 closePictureInPicture: operations.closePictureInPicture,
3900 startRecording: operations.startRecording,
3901 stopRecording: operations.stopRecording,
3902 saveRecording: operations.saveRecording,
3903 subscribeStateChanges: operations.subscribeStateChanges,
3904 subscribePointerEvents: operations.subscribePointerEvents,
3905 subscribeOpenLinks: operations.subscribeOpenLinks,
3906 subscribeRecordingFrames: operations.subscribeRecordingFrames,
3907 subscribeRecordingInputs: operations.subscribeRecordingInputs,
3908 });
3909}).pipe(Effect.withSpan("PreviewManager.make"));
3910
3911export const layer = Layer.effect(PreviewManager, make);