// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem.copyFile cannot request an exclusive copy. import * as NodeFSP from "node:fs/promises"; import * as NodeZlib from "node:zlib"; import { DesktopPreviewAnnotationVoiceEventSchema, DesktopPreviewRecordingInputSchema, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, } from "@t3tools/contracts"; import type { DesktopPreviewAnnotationTheme, DesktopPreviewAnnotationVoiceConfig, DesktopPreviewAnnotationVoiceState, DesktopPreviewColorScheme, DesktopPreviewFavicon, DesktopPreviewFindResultEvent, DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, PreviewAnnotationSubmissionResult, DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, DesktopPreviewRecordingInputEvent, DesktopPreviewScreenshotArtifact, DesktopPreviewTabDefaults, PreviewForwardedShortcut, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { BrowserWindow, ClipboardItem, app, type Session, clipboard, nativeImage, shell, webContents, } from "electron"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Predicate from "effect/Predicate"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; import { MENU_ACTION_CHANNEL, PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, } from "../ipc/channels.ts"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; import * as PreviewPasskeys from "./Passkeys.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { ANNOTATION_CAPTURED_CHANNEL, ANNOTATION_SEND_ENABLED_CHANNEL, ANNOTATION_VOICE_EVENT_CHANNEL, ANNOTATION_VOICE_STATE_CHANNEL, ANNOTATION_THEME_CHANNEL, CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, RECORDING_INPUT_CHANNEL, RECORDING_CONTROLLER_CHANNEL, RECORDING_POINTER_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; import { DEFAULT_RECORDING_INPUT_OPTIONS, type RecordingInputOptions } from "./RecordingInput.ts"; export type PreviewNavStatus = | { kind: "Idle" } | { kind: "Loading"; url: string; title: string } | { kind: "Success"; url: string; title: string } | { kind: "LoadFailed"; url: string; title: string; code: number; description: string; }; export interface PreviewTabState { tabId: string; webContentsId: number | null; navStatus: PreviewNavStatus; canGoBack: boolean; canGoForward: boolean; zoomFactor: number; pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; audioMuted: boolean; audible: boolean; controller: "human" | "agent" | "none"; capturing: boolean; favicon?: DesktopPreviewFavicon; serverTab?: { readonly threadId: string; readonly tabId: string }; updatedAt: string; } const ZOOM_LEVELS: ReadonlyArray = [ 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, ]; const DEFAULT_ZOOM_FACTOR = 1.0; const ZOOM_EPSILON = 0.001; const RECORDING_ARM_GRACE_MS = 10_000; const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; // Cold guests can reject capturePage with UnknownVizError or stall; bound each attempt. const CAPTURE_PAGE_RETRY_ATTEMPTS = 3; const CAPTURE_PAGE_RETRY_DELAY_MS = 120; const CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS = 1_000; const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320; const PICTURE_IN_PICTURE_MIN_WIDTH = 240; const PICTURE_IN_PICTURE_MIN_HEIGHT = 160; const PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON = 0.002; const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; const requestRecordingCaptureExpression = (tabId: string): string => `globalThis[${JSON.stringify(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER)}]?.(${JSON.stringify(tabId)}) === true`; const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { colorScheme: "light", radius: "0.625rem", background: "white", foreground: "oklch(0.269 0 0)", popover: "white", popoverForeground: "oklch(0.269 0 0)", primary: "oklch(0.488 0.217 264)", primaryForeground: "white", muted: "rgb(0 0 0 / 4%)", mutedForeground: "oklch(0.556 0 0)", accent: "rgb(0 0 0 / 4%)", accentForeground: "oklch(0.269 0 0)", border: "rgb(0 0 0 / 8%)", input: "rgb(0 0 0 / 10%)", ring: "oklch(0.488 0.217 264)", fontSans: "system-ui, sans-serif", fontMono: "ui-monospace, monospace", }; const buildPreviewPictureInPictureDataUrl = (): string => { const html = ` Live browser preview `; return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; }; export const fitPictureInPictureContentSize = ( current: ReadonlyArray, aspectRatio: number, ): readonly [width: number, height: number] => { const currentWidth = Math.max(1, current[0] ?? PICTURE_IN_PICTURE_INITIAL_WIDTH); const currentHeight = Math.max(1, current[1] ?? PICTURE_IN_PICTURE_INITIAL_HEIGHT); const currentArea = currentWidth * currentHeight; let width = Math.sqrt(currentArea * aspectRatio); let height = width / aspectRatio; const minimumScale = Math.max( 1, PICTURE_IN_PICTURE_MIN_WIDTH / width, PICTURE_IN_PICTURE_MIN_HEIGHT / height, ); width *= minimumScale; height *= minimumScale; return [Math.round(width), Math.round(height)]; }; const recordingFileExtension = (mimeType: string): string => { const subtype = mimeType.split(";", 1)[0]?.trim().toLowerCase().split("/")[1] ?? ""; const extension = subtype.replace(/^x-/, "").replace(/[^a-z0-9]/g, ""); return extension || "video"; }; const artifactSiteSlug = (rawUrl: string): string => { try { const url = new URL(rawUrl); const slug = url.hostname .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, MAX_ARTIFACT_SITE_SLUG_LENGTH) .replace(/-+$/g, ""); return slug || "site"; } catch { return "site"; } }; export const PreviewAutomationSelectorKind = Schema.Literals([ "focused-element", "selector", "locator", ]); export type PreviewAutomationSelectorKind = typeof PreviewAutomationSelectorKind.Type; export const PreviewAutomationEvaluationDetailKind = Schema.Literals([ "exception-description", "exception-text", "unknown", ]); export type PreviewAutomationEvaluationDetailKind = typeof PreviewAutomationEvaluationDetailKind.Type; interface PreviewOperationContext { readonly operation: string; readonly tabId?: string; readonly webContentsId?: number; readonly artifactPath?: string; } const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { if (typeof value !== "object" || value === null) return null; const rect = value as Record; const x = rect["x"]; const y = rect["y"]; const width = rect["width"]; const height = rect["height"]; if ( typeof x !== "number" || !Number.isFinite(x) || typeof y !== "number" || !Number.isFinite(y) || typeof width !== "number" || !Number.isFinite(width) || typeof height !== "number" || !Number.isFinite(height) || width <= 0 || height <= 0 ) { return null; } return { x, y, width, height }; }; // capturePage returns a 1x NativeImage containing device pixels, so crop() uses // CSS coordinates scaled by the guest's devicePixelRatio (including page zoom). const cropAnnotationScreenshot = ( image: Electron.NativeImage, cropRect: PreviewAnnotationRect | null, devicePixelRatio: number, targetRects: ReadonlyArray, ): PreviewAnnotationPayload["screenshot"] => { const full = image.getSize(); if (full.width <= 0 || full.height <= 0) return null; const scale = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1; // Padding or a union of offscreen targets can overlap an empty viewport area. // A zero-width/height SVG line can still paint at the top or left edge. if ( targetRects.length > 0 && !targetRects.some( (rect) => (rect.width > 0 || rect.height > 0) && rect.x * scale < full.width && rect.y * scale < full.height && (rect.width === 0 ? rect.x >= 0 : rect.x + rect.width > 0) && (rect.height === 0 ? rect.y >= 0 : rect.y + rect.height > 0), ) ) { return null; } if (!cropRect) { return { dataUrl: image.toDataURL(), width: full.width, height: full.height, cropRect: { x: 0, y: 0, width: full.width, height: full.height }, }; } const x = Math.max(0, Math.floor(cropRect.x * scale)); const y = Math.max(0, Math.floor(cropRect.y * scale)); const right = Math.min(full.width, Math.ceil((cropRect.x + cropRect.width) * scale)); const bottom = Math.min(full.height, Math.ceil((cropRect.y + cropRect.height) * scale)); // A target scrolled out of view has no pixels in this capture. Clamping its // origin onto the edge would attach an unrelated strip as a successful crop. if (right <= x || bottom <= y) return null; const cropped = image.crop({ x, y, width: right - x, height: bottom - y, }); const size = cropped.getSize(); if (size.width <= 0 || size.height <= 0) return null; return { dataUrl: cropped.toDataURL(), width: size.width, height: size.height, cropRect }; }; // An invalid color space skips costly P3-to-sRGB conversion; fingerprints need no color accuracy. const FINGERPRINT_BITMAP_OPTIONS: Electron.ToBitmapOptions = { colorSpace: { primaries: "invalid", transfer: "invalid", matrix: "invalid", range: "invalid" }, }; /** Empty readPixels results have no fingerprint and must be treated as changed frames. */ const fingerprintCapturedFrame = (image: Electron.NativeImage, size: Electron.Size) => { let bitmap: Buffer; try { bitmap = image.toBitmap(FINGERPRINT_BITMAP_OPTIONS); } catch { return null; } if (bitmap.byteLength === 0) return null; return `${size.width}x${size.height}:${NodeZlib.crc32(bitmap)}`; }; const findZoomStep = (current: number): number => { const index = ZOOM_LEVELS.findIndex( (level) => Math.abs(level - current) < ZOOM_EPSILON || level > current, ); if (index < 0) return ZOOM_LEVELS.length - 1; return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1; }; const normalizeZoomFactor = (value: number | undefined): number => { if (value === undefined || !Number.isFinite(value)) return DEFAULT_ZOOM_FACTOR; let closest = ZOOM_LEVELS[0]!; for (const level of ZOOM_LEVELS) { if (Math.abs(level - value) < Math.abs(closest - value)) closest = level; } return closest; }; const nextZoomLevel = (current: number, direction: "in" | "out"): number => { const step = findZoomStep(current); if (direction === "in") { return ZOOM_LEVELS[Math.min(step + 1, ZOOM_LEVELS.length - 1)] ?? current; } return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current; }; type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect; type PreviewInputSignal = | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } | { readonly kind: "key"; readonly key: string; readonly code: string }; interface ManagedListeners { readonly attachmentId: symbol; readonly cancelFaviconCapture: () => void; readonly scope: Scope.Closeable; readonly webContents: Electron.WebContents; } type FrameCaptureConsumer = "picture-in-picture" | "recording"; interface FrameCaptureSession { readonly recordingInputOptions?: RecordingInputOptions; readonly scope: Scope.Closeable | null; readonly consumers: ReadonlySet; readonly unthrottledWebContentsIds: ReadonlySet; readonly lastPictureInPictureFingerprint: string | null; } interface PictureInPictureSession { readonly window: BrowserWindow; readonly webContentsId: number; readonly ready: Deferred.Deferred; readonly initializationScope: Scope.Closeable; } interface PendingRecording { readonly tabId: string; readonly webContents: Electron.WebContents; readonly requestingFrameTreeNodeId: number; readonly armedAtMillis: number; } interface PickSession { readonly voiceSessionId?: string | undefined; readonly cancel: Effect.Effect; } const decodeAnnotationVoiceEvent = Schema.decodeUnknownOption( DesktopPreviewAnnotationVoiceEventSchema, ); interface BrowserControlSession { readonly webContentsId: number; // Retain the attached Debugger to prevent GC crashes (electron/electron#53376); wc.debugger throws after destruction. readonly debugger: Electron.Debugger; readonly semaphore: Semaphore.Semaphore; readonly scope: Scope.Closeable; readonly onMessage: ( event: Electron.Event, method: string, params: Record, ) => void; } const isRecordingInput = Schema.is(DesktopPreviewRecordingInputSchema); type RecordingInputListener = (event: DesktopPreviewRecordingInputEvent) => Effect.Effect; type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect; type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect; type FindResultListener = (event: DesktopPreviewFindResultEvent) => Effect.Effect; const PAGE_FIRST_FORWARDED_COMMANDS: ReadonlySet = new Set([ "preview.find", ]); const decodePageShortcutKeys = Schema.decodeUnknownOption( Schema.Struct({ key: Schema.String, code: Schema.String, metaKey: Schema.Boolean, ctrlKey: Schema.Boolean, shiftKey: Schema.Boolean, altKey: Schema.Boolean, }), ); // about:blank inherits relaxed guest preferences; external editor schemes must reach the OS handler. const POPUP_PROTOCOLS = new Set(["http:", "https:"]); // Main-process loadURL bypasses Chromium's renderer-side restrictions on local and inline URLs. const IN_PLACE_DENIED_PROTOCOLS = new Set([ "about:", "chrome:", "data:", "devtools:", "file:", "filesystem:", "javascript:", "view-source:", ]); /** Popups bypass will-attach-webview hardening and would inherit the picker's contextIsolation:false. */ const POPUP_WINDOW_OPTIONS = { webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, }, } satisfies Electron.BrowserWindowConstructorOptions; /** * OAuth needs a surviving popup opener. `blob:`, app links (behind the session's `openExternal` * prompt) and form POSTs stay in place. */ export const previewWindowOpenAction = (details: { readonly url: string; readonly disposition: Electron.HandlerDetails["disposition"]; readonly postBody?: Electron.PostBody; }): "popup" | "new-tab" | "navigate" | "deny" => { const protocol = URL.parse(details.url)?.protocol; // MSAL redirects on a denied blank popup; navigating the tab would destroy that fallback. if (protocol === undefined || IN_PLACE_DENIED_PROTOCOLS.has(protocol)) return "deny"; if (!POPUP_PROTOCOLS.has(protocol)) return "navigate"; if (details.disposition === "new-window") return "popup"; return !details.postBody && (details.disposition === "foreground-tab" || details.disposition === "background-tab") ? "new-tab" : "navigate"; }; const isPreviewRefreshShortcut = (input: Electron.Input, platform: NodeJS.Platform): boolean => input.type === "keyDown" && input.key.toLowerCase() === "r" && (platform === "darwin" ? input.meta && !input.control : input.control && !input.meta) && !input.shift && !input.alt; export const isPreviewEditingShortcut = ( input: Electron.Input, platform: NodeJS.Platform, ): boolean => { const isMac = platform === "darwin"; if (isMac ? !input.meta || input.control : !input.control || input.meta) return false; const key = input.key.toLowerCase(); // Option changes the DOM key for macOS Paste and Match Style (for example, to ◊). if (isMac && input.alt && input.shift && input.code === "KeyV") return true; if (key === "v" && input.shift) return input.alt === isMac; if (input.alt) return false; if (key === "z") return !input.shift || platform !== "win32"; if (input.shift) return false; return ( key === "a" || key === "c" || key === "v" || key === "x" || (key === "y" && platform === "win32") ); }; const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { if (typeof value !== "object" || value === null || !("kind" in value)) return false; if (value.kind === "pointer") { return ( "x" in value && typeof value.x === "number" && "y" in value && typeof value.y === "number" && "button" in value && typeof value.button === "number" ); } return ( value.kind === "key" && "key" in value && typeof value.key === "string" && "code" in value && typeof value.code === "string" ); }; const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* ( artifactDirectory: string, pictureInPicturePreloadPath: string, ) { const fileSystem = yield* FileSystem.FileSystem; const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; const hostPlatform = yield* HostProcess.Platform; const path = yield* Path.Path; const random = yield* SecureRandom.SecureRandom; const parentScope = yield* Scope.Scope; const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost; const passkeys = yield* PreviewPasskeys.PreviewPasskeys; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const resolvedArtifactDirectory = path.resolve(artifactDirectory); let forwardedShortcuts: ReadonlyArray = []; const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME); const annotationSendEnabled = new Map(); const mainWindowRef = yield* Ref.make>(Option.none()); const tabsRef = yield* SynchronizedRef.make>(new Map()); const attachedRef = yield* Ref.make>(new Map()); const listenersRef = yield* Ref.make>(new Set()); const pointerEventListenersRef = yield* Ref.make>(new Set()); const openLinkListenersRef = yield* Ref.make>(new Set()); const findResultListenersRef = yield* Ref.make>(new Set()); const recordingInputListenersRef = yield* Ref.make>( new Set(), ); const pickSessionsRef = yield* Ref.make>(new Map()); const controlSessionsRef = yield* SynchronizedRef.make< ReadonlyMap >(new Map()); const frameCaptureSessionsRef = yield* SynchronizedRef.make< ReadonlyMap >(new Map()); const pictureInPictureSessionsRef = yield* SynchronizedRef.make< ReadonlyMap >(new Map()); const pictureInPictureAspectRatiosRef = yield* Ref.make>(new Map()); const pictureInPictureMutationSemaphore = yield* Semaphore.make(1); const closingTabIdsRef = yield* Ref.make>(new Set()); // Legacy tab capture is unsupported (electron/electron#44618); recording uses setDisplayMediaRequestHandler. let pendingRecording: PendingRecording | null = null; const displayMediaHandlerSessions = new WeakSet(); let frameCaptureWindowOpen = true; let currentMainWindow: BrowserWindow | undefined; let mainWindowCleanupFiber: Fiber.Fiber | undefined; const tabLifecycleLocks = new Map< string, { readonly semaphore: Semaphore.Semaphore; users: number } >(); const tabLifecycleGenerations = new Map(); const attempt = (errorContext: PreviewOperationContext, evaluate: () => A) => Effect.try({ try: evaluate, catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), }); const attemptPromise = ( errorContext: PreviewOperationContext, evaluate: () => PromiseLike, ) => Effect.tryPromise({ try: evaluate, catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), }); const capturePageWithRetry = Effect.fn("PreviewManager.capturePageWithRetry")(function* ( errorContext: PreviewOperationContext, tabId: string, wc: Electron.WebContents, ) { const requireCurrentGuest = Effect.gen(function* () { const tabs = yield* SynchronizedRef.get(tabsRef); if (wc.isDestroyed() || tabs.get(tabId)?.webContentsId !== wc.id) { return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId: wc.id }); } }); const capture = Effect.gen(function* () { yield* requireCurrentGuest; const image = yield* Effect.tryPromise({ try: (_signal) => wc.capturePage(), catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), }).pipe( Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS), Effect.catchTags({ TimeoutError: (cause) => Effect.fail(new PreviewOperationError({ ...errorContext, cause })), }), ); yield* requireCurrentGuest; return image; }); return yield* capture.pipe( Effect.retry({ times: CAPTURE_PAGE_RETRY_ATTEMPTS - 1, schedule: Schedule.spaced(CAPTURE_PAGE_RETRY_DELAY_MS), while: isPreviewOperationError, }), ); }); const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); const currentMillis = Clock.currentTimeMillis; const replaceMap = ( source: ReadonlyMap, update: (copy: Map) => void, ): ReadonlyMap => { const copy = new Map(source); update(copy); return copy; }; const withTabLifecycleLock = ( tabId: string, effect: Effect.Effect, ): Effect.Effect => Effect.suspend(() => { const lifecycle = tabLifecycleLocks.get(tabId) ?? { semaphore: Semaphore.makeUnsafe(1), users: 0, }; lifecycle.users += 1; tabLifecycleLocks.set(tabId, lifecycle); return lifecycle.semaphore.withPermit(effect).pipe( Effect.ensuring( Effect.sync(() => { lifecycle.users -= 1; if (lifecycle.users === 0 && tabLifecycleLocks.get(tabId) === lifecycle) { tabLifecycleLocks.delete(tabId); } }), ), ); }); const setWindowBackgroundThrottling = Effect.fnUntraced(function* ( window: BrowserWindow, enabled: boolean, ) { if (window.isDestroyed()) return; yield* attempt({ operation: "frameCapture.setBackgroundThrottling" }, () => window.webContents.setBackgroundThrottling(enabled), ); }); const setFrameCaptureBackgroundThrottling = Effect.fnUntraced(function* (enabled: boolean) { const mainWindow = yield* Ref.get(mainWindowRef); if (Option.isNone(mainWindow)) return; yield* setWindowBackgroundThrottling(mainWindow.value, enabled); }); const setFrameCaptureWebContentsBackgroundThrottling = Effect.fnUntraced(function* ( wc: Electron.WebContents, enabled: boolean, ) { if (wc.isDestroyed()) return; yield* attempt( { operation: "frameCapture.setBackgroundThrottling", webContentsId: wc.id, }, () => wc.setBackgroundThrottling(enabled), ); }); const restoreFrameCaptureWebContentsBackgroundThrottling = Effect.fnUntraced(function* ( webContentsIds: ReadonlySet, ) { yield* Effect.forEach( webContentsIds, (webContentsId) => { const wc = webContents.fromId(webContentsId); if (!wc || wc.isDestroyed()) return Effect.void; return setFrameCaptureWebContentsBackgroundThrottling(wc, true).pipe( Effect.retry({ times: 2 }), Effect.catch((error) => Effect.logWarning("Failed to restore preview webview frame capture throttling.", { webContentsId, error, }), ), ); }, { concurrency: "unbounded", discard: true }, ); }); const keepFrameCaptureWebContentsUnthrottled = Effect.fnUntraced(function* ( tabId: string, wc: Electron.WebContents, ) { yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => { const current = sessions.get(tabId); if (!current || current.unthrottledWebContentsIds.has(wc.id)) { return Effect.succeed([undefined, sessions] as const); } return setFrameCaptureWebContentsBackgroundThrottling(wc, false).pipe( Effect.tap(() => Effect.gen(function* () { if (!current.consumers.has("recording")) return; const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); yield* attempt({ operation: "recording.cursor", tabId, webContentsId: wc.id }, () => wc.send( RECORDING_CURSOR_CHANNEL, true, current.recordingInputOptions, tab?.controller, ), ); }), ), Effect.map( () => [ undefined, replaceMap(sessions, (copy) => { copy.set(tabId, { ...current, unthrottledWebContentsIds: new Set([...current.unthrottledWebContentsIds, wc.id]), }); }), ] as const, ), ); }); }); const stopFrameCapture = Effect.fn("PreviewManager.stopFrameCapture")(function* ( tabId: string, consumer: FrameCaptureConsumer, ) { yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => Effect.gen(function* () { const current = sessions.get(tabId); if (!current || !current.consumers.has(consumer)) { return [undefined, sessions] as const; } if (consumer === "recording") { yield* Effect.forEach(current.unthrottledWebContentsIds, (id) => attempt({ operation: "recording.cursor", tabId, webContentsId: id }, () => { const contents = webContents.fromId(id); if (contents && !contents.isDestroyed()) contents.send(RECORDING_CURSOR_CHANNEL, false); }).pipe(Effect.ignore), ); } const consumers = new Set(current.consumers); consumers.delete(consumer); if (consumers.size > 0) { return [ consumer === "picture-in-picture" ? current.scope : undefined, replaceMap(sessions, (copy) => { copy.set(tabId, { ...current, scope: consumer === "picture-in-picture" ? null : current.scope, consumers, lastPictureInPictureFingerprint: consumer === "picture-in-picture" ? null : current.lastPictureInPictureFingerprint, }); }), ] as const; } const remainingSessions = replaceMap(sessions, (copy) => { copy.delete(tabId); }); yield* restoreFrameCaptureWebContentsBackgroundThrottling( current.unthrottledWebContentsIds, ); if (remainingSessions.size === 0) { yield* setFrameCaptureBackgroundThrottling(true).pipe( Effect.retry({ times: 2 }), Effect.catch((error) => Effect.logWarning("Failed to restore preview frame capture throttling.", { error }), ), ); } return [current.scope, remainingSessions] as const; }), ).pipe( Effect.flatMap((captureScope) => captureScope ? Scope.close(captureScope, Exit.void).pipe(Effect.ignore) : Effect.void, ), Effect.uninterruptible, ); }); const stopAllRecordings = Effect.fn("PreviewManager.stopAllRecordings")(function* () { pendingRecording = null; const sessions = yield* SynchronizedRef.get(frameCaptureSessionsRef); yield* Effect.forEach(sessions.keys(), (tabId) => stopFrameCapture(tabId, "recording"), { concurrency: "unbounded", discard: true, }); }); const deliverEvent = ( eventKind: "state-change" | "recording-input" | "pointer-event" | "open-link" | "find-result", tabId: string, delivery: () => Effect.Effect, ) => Effect.suspend(delivery).pipe( Effect.catchCauseIf( (cause) => !Cause.hasInterrupts(cause), (cause) => Effect.logWarning("Desktop preview event listener failed.", { eventKind, tabId, cause, }), ), ); const emit = Effect.fn("PreviewManager.emit")(function* (tabId: string, state: PreviewTabState) { const listeners = yield* Ref.get(listenersRef); yield* Effect.forEach( listeners, (listener) => deliverEvent("state-change", tabId, () => listener(tabId, state)), { discard: true }, ); }); const emitIfCurrent = Effect.fn("PreviewManager.emitIfCurrent")(function* ( tabId: string, state: PreviewTabState, ) { if ((yield* SynchronizedRef.get(tabsRef)).get(tabId) === state) { yield* emit(tabId, state); } }); const update = Effect.fn("PreviewManager.update")(function* ( tabId: string, patch: Partial, humanPoint?: { readonly x: number; readonly y: number }, ) { const updatedAt = yield* currentIso; const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); if (!current) return [Option.none(), tabs] as const; const state: PreviewTabState = { ...current, ...patch, updatedAt }; return [ Option.some(state), replaceMap(tabs, (copy) => { copy.set(tabId, state); }), ] as const; }); // syncTabAudible can commit after this update; emitIfCurrent prevents publishing a stale snapshot. if (Option.isSome(next)) { if (patch.controller !== undefined && next.value.webContentsId != null) { const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); const webContentsId = next.value.webContentsId; if (capture?.consumers.has("recording")) { yield* attempt({ operation: "recording.controller", tabId }, () => { const contents = webContents.fromId(webContentsId); if (contents && !contents.isDestroyed()) contents.send(RECORDING_CONTROLLER_CHANNEL, patch.controller, humanPoint); }).pipe(Effect.ignore); } } yield* emitIfCurrent(tabId, next.value); } }); /** Read zoom after awaits so concurrent zoom actions cannot be rolled back by an older snapshot. */ const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab || tab.webContentsId == null) return; const wc = webContents.fromId(tab.webContentsId); if (!wc || wc.isDestroyed()) return; yield* attempt({ operation: "assertTabZoom", tabId, webContentsId: wc.id }, () => wc.setZoomFactor(tab.zoomFactor), ).pipe(Effect.ignore); }); /** Read committed mute and current guest at call time so stale snapshots cannot roll back newer actions. */ const assertTabAudioMuted = Effect.fn("PreviewManager.assertTabAudioMuted")(function* ( tabId: string, ) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab || tab.webContentsId == null) return; const wc = webContents.fromId(tab.webContentsId); if (!wc || wc.isDestroyed()) return; yield* attempt({ operation: "assertTabAudioMuted", tabId, webContentsId: wc.id }, () => wc.setAudioMuted(tab.audioMuted), ); }); const syncTabAudible = Effect.fn("PreviewManager.syncTabAudible")(function* ( tabId: string, wc: Electron.WebContents, audible: boolean, ) { if (wc.isDestroyed()) return; const updatedAt = yield* currentIso; const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); if ( !current || current.webContentsId !== wc.id || webContents.fromId(wc.id) !== wc || current.audible === audible ) { return [Option.none(), tabs] as const; } const state: PreviewTabState = { ...current, audible, updatedAt }; return [ Option.some(state), replaceMap(tabs, (copy) => { copy.set(tabId, state); }), ] as const; }); if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value); }); const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* ( tabId: string, ) { const tabs = yield* SynchronizedRef.get(tabsRef); const tab = tabs.get(tabId); if (!tab) { return yield* new PreviewTabNotFoundError({ tabId }); } if (tab.webContentsId == null) { return yield* new PreviewWebviewNotInitializedError({ tabId }); } const wc = webContents.fromId(tab.webContentsId); if (!wc) { return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId: tab.webContentsId, }); } return wc; }); const resolveArtifactPath = (artifactPath: string) => attempt({ operation: "resolveArtifactPath", artifactPath }, () => { const resolvedPath = path.resolve(artifactPath); const relativePath = path.relative(resolvedArtifactDirectory, resolvedPath); if ( relativePath.length === 0 || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath) ) { return null; } return resolvedPath; }).pipe( Effect.filterOrFail( (resolvedPath) => resolvedPath !== null, () => new PreviewArtifactPathOutsideDirectoryError({ artifactPath, artifactDirectory: resolvedArtifactDirectory, }), ), ); const tabIdForWebContents = Effect.fnUntraced(function* (webContentsId: number) { const tabs = yield* SynchronizedRef.get(tabsRef); return ( Array.from(tabs.entries()).find(([, tab]) => tab.webContentsId === webContentsId)?.[0] ?? null ); }); const detachControlSession = Effect.fn("PreviewManager.detachControlSession")(function* ( webContentsId: number, closedServerTab?: PreviewTabState["serverTab"], ) { const control = yield* SynchronizedRef.modify(controlSessionsRef, (sessions) => [ sessions.get(webContentsId), replaceMap(sessions, (copy) => { copy.delete(webContentsId); }), ]); if (control) yield* releaseControlSession(control, closedServerTab); }); // Closes a session already removed from `controlSessionsRef`. The server can // only drive a tab while the desktop holds its debugger. const releaseControlSession = Effect.fnUntraced(function* ( control: BrowserControlSession, closedServerTab?: PreviewTabState["serverTab"], ) { if (closedServerTab) browserHost.detach(closedServerTab); for (const tab of (yield* SynchronizedRef.get(tabsRef)).values()) { if (tab.webContentsId === control.webContentsId && tab.serverTab) { browserHost.detach(tab.serverTab); } } yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore); }); const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* ( wc: Electron.WebContents, ) { return yield* SynchronizedRef.modifyEffect( controlSessionsRef, ( sessions, ): Effect.Effect< readonly [BrowserControlSession, ReadonlyMap], PreviewManagerError > => { const existing = sessions.get(wc.id); if (existing) return Effect.succeed([existing, sessions] as const); if (wc.isDestroyed()) { return Effect.fail( new PreviewOperationError({ operation: "ensureControlSession", webContentsId: wc.id, cause: new Error("WebContents was destroyed"), }), ); } if (wc.isDevToolsOpened()) { return Effect.fail( new PreviewAutomationDevToolsOpenError({ webContentsId: wc.id, }), ); } if (wc.debugger.isAttached()) { return Effect.fail( new PreviewAutomationDebuggerAttachedError({ webContentsId: wc.id, }), ); } const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { const semaphore = yield* Semaphore.make(1); const scope = yield* Scope.fork(parentScope, "sequential"); const wcDebugger = wc.debugger; const consoleReleases = yield* Queue.sliding(1); // Console eviction leaves strong debugger handles; release the group because we keep only text. yield* Effect.forkIn( Effect.forever( Queue.take(consoleReleases).pipe( Effect.andThen( attemptPromise({ operation: "releaseConsoleObjects", webContentsId: wc.id }, () => wcDebugger.sendCommand("Runtime.releaseObjectGroup", { objectGroup: "console", }), ).pipe(Effect.ignore), ), ), ), scope, ); const handleDebuggerMessage = Effect.fnUntraced(function* ( method: string, params: Record, ) { if (method === "Page.screencastFrame") { const sessionId = params["sessionId"]; if (typeof sessionId === "number") { yield* attemptPromise( { operation: "ackScreencastFrame", webContentsId: wc.id, }, () => wcDebugger.sendCommand("Page.screencastFrameAck", { sessionId }), ).pipe(Effect.ignore); } } if (method === "Runtime.consoleAPICalled" || method === "Runtime.exceptionThrown") { yield* Queue.offer(consoleReleases, undefined); } }); const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { runFork(handleDebuggerMessage(method, params)); }; const control: BrowserControlSession = { webContentsId: wc.id, debugger: wcDebugger, semaphore, scope, onMessage, }; // Electron emits `detach` when Chromium closes the debugger session on its // own. The cached session would then fail every command until the webview // is replaced. let dropped = false; const onDetach = () => { dropped = true; runFork(recoverDetachedControlSession(wc, control)); }; // The listener comes off before detach() so a detach this manager requested // never looks like a dropped one. A dropped session has nothing left to // detach: `wc.debugger` is shared, so detaching here would hit a successor. yield* Scope.addFinalizer( scope, attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => { wcDebugger.off("message", onMessage); wcDebugger.off("detach", onDetach); if (!dropped && wcDebugger.isAttached()) wcDebugger.detach(); }).pipe(Effect.ignore), ); const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () { yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => { wcDebugger.on("message", onMessage); wcDebugger.on("detach", onDetach); wcDebugger.attach("1.3"); }); // Set an opaque base before first paint or background-less dark pages retain white-on-white text. yield* attemptPromise( { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id }, () => wcDebugger.sendCommand("Emulation.setDefaultBackgroundColorOverride", { color: { r: 255, g: 255, b: 255, a: 1 }, }), ); return [ control, replaceMap(sessions, (copy) => { copy.set(wc.id, control); }), ] as const; }); return yield* initialize().pipe( Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), ); }); return createControlSession(); }, ); }); const cancelPickElement = Effect.fn("PreviewManager.cancelPickElement")(function* ( tabId: string, ) { const session = (yield* Ref.get(pickSessionsRef)).get(tabId); if (session) yield* session.cancel; }); const detachListeners = Effect.fn("PreviewManager.detachListeners")(function* ( webContentsId: number, ) { const managed = yield* Ref.modify(attachedRef, (attached) => [ attached.get(webContentsId), replaceMap(attached, (copy) => { copy.delete(webContentsId); }), ]); if (managed) { managed.cancelFaviconCapture(); yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore); } }); const computeNavStatus = (wc: Electron.WebContents): PreviewNavStatus => { const url = wc.getURL(); const title = wc.getTitle(); if (url === "" || url === "about:blank") return { kind: "Idle" }; // isLoading() includes late iframes whose completion may never clear the tab's loading bar. if (wc.isLoadingMainFrame()) return { kind: "Loading", url, title }; return { kind: "Success", url, title }; }; const attachListeners = Effect.fn("PreviewManager.attachListeners")(function* ( tabId: string, wc: Electron.WebContents, ) { const scope = yield* Scope.fork(parentScope, "sequential"); const attachmentId = Symbol(); let detachPasskeys = () => {}; let documentId = 0; let nextRequestId = 0; let activeCapture: { readonly controller: AbortController; readonly documentId: number; readonly eventKey: string; readonly requestId: number; } | null = null; const cancelFaviconCapture = () => { documentId += 1; activeCapture?.controller.abort(); activeCapture = null; }; const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* ( preserveLoadFailure: boolean, confirmedNavigation = false, ) { if (wc.isDestroyed()) return; const computedNavStatus = computeNavStatus(wc); const canGoBack = wc.navigationHistory.canGoBack(); const canGoForward = wc.navigationHistory.canGoForward(); const updatedAt = yield* currentIso; const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); if (!current || current.webContentsId !== wc.id || webContents.fromId(wc.id) !== wc) { return [Option.none(), tabs] as const; } // did-stop-loading follows did-fail-load without proving successful navigation. const navStatus = preserveLoadFailure && current.navStatus.kind === "LoadFailed" && computedNavStatus.kind === "Success" ? current.navStatus : computedNavStatus; const clearFavicon = confirmedNavigation && current.favicon !== undefined && safeHttpOrigin(current.favicon.pageUrl) !== safeHttpOrigin(navStatus.kind === "Idle" ? wc.getURL() : navStatus.url); const { favicon: _favicon, ...currentWithoutFavicon } = current; const state: PreviewTabState = { ...(clearFavicon ? currentWithoutFavicon : current), navStatus, canGoBack, canGoForward, // Guest zoom inherits app zoom; mirroring it would overwrite the tab's own setting. updatedAt, }; return [ Option.some(state), replaceMap(tabs, (copy) => { copy.set(tabId, state); }), ] as const; }); if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value); }); const sync = () => runFork(syncState(true)); const syncNavigation = () => runFork(syncState(false, true)); const syncInPageNavigation = () => runFork(syncState(false)); const restoreRecordingCursor = () => runFork( Effect.gen(function* () { const session = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); if (!wc.isDestroyed()) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); wc.send( RECORDING_CURSOR_CHANNEL, session?.consumers.has("recording") ?? false, session?.recordingInputOptions, tab?.controller, ); } }), ); const navigationStarted = ( event: Electron.Event, ) => { if (event.isMainFrame && !event.isSameDocument) cancelFaviconCapture(); }; const audioStateChanged = ( event: Electron.Event, ) => runFork(syncTabAudible(tabId, wc, event.audible)); const publishFavicon = Effect.fn("PreviewManager.publishFavicon")(function* (input: { readonly captureDocumentId: number; readonly dataUrl: string; readonly pageUrl: string; readonly requestId: number; }) { const pageOrigin = safeHttpOrigin(input.pageUrl); const managed = (yield* Ref.get(attachedRef)).get(wc.id); if ( !pageOrigin || wc.isDestroyed() || webContents.fromId(wc.id) !== wc || managed?.attachmentId !== attachmentId || activeCapture?.documentId !== input.captureDocumentId || activeCapture.requestId !== input.requestId || safeHttpOrigin(wc.getURL()) !== pageOrigin ) { return; } const capturedAt = yield* currentMillis; const updatedAt = yield* currentIso; const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); if ( !current || current.webContentsId !== wc.id || webContents.fromId(wc.id) !== wc || activeCapture?.documentId !== input.captureDocumentId || activeCapture.requestId !== input.requestId ) { return [Option.none(), tabs] as const; } const state: PreviewTabState = { ...current, favicon: { dataUrl: input.dataUrl, pageUrl: pageOrigin, capturedAt }, updatedAt, }; return [ Option.some(state), replaceMap(tabs, (copy) => { copy.set(tabId, state); }), ] as const; }); if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value); }); const faviconUpdated = (_event: Event, rawCandidates: ReadonlyArray): void => { const pageUrl = wc.getURL(); if (!safeHttpOrigin(pageUrl)) return; const candidates = selectFaviconCandidates(rawCandidates); if (candidates.length === 0) return; const eventKey = JSON.stringify([pageUrl, ...candidates]); if (activeCapture?.eventKey === eventKey) return; activeCapture?.controller.abort(); const captureDocumentId = documentId; const requestId = ++nextRequestId; const controller = new AbortController(); activeCapture = { controller, documentId: captureDocumentId, eventKey, requestId }; runFork( Effect.tryPromise({ try: () => captureFavicon({ webContents: wc, pageUrl, candidates, signal: controller.signal }), catch: (cause) => new PreviewOperationError({ operation: "captureFavicon", tabId, webContentsId: wc.id, cause, }), }).pipe( Effect.flatMap((result) => result.kind === "captured" ? publishFavicon({ captureDocumentId, dataUrl: result.dataUrl, pageUrl, requestId, }) : Effect.void, ), Effect.catch((error) => controller.signal.aborted ? Effect.void : Effect.logDebug("Favicon capture failed.", { error, tabId, webContentsId: wc.id }), ), Effect.ensuring( Effect.sync(() => { if (activeCapture?.requestId === requestId) activeCapture = null; }), ), ), ); }; const failed = ( _event: Event, code: number, description: string, validatedUrl: string, isMainFrame: boolean, ): void => { if (code === -3 || !isMainFrame) return; runFork( update(tabId, { navStatus: { kind: "LoadFailed", url: validatedUrl || wc.getURL(), title: wc.getTitle(), code, description, }, }), ); }; const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* ( rawSignal?: unknown, ) { yield* update( tabId, { controller: "human" }, isPreviewInputSignal(rawSignal) && rawSignal.kind === "pointer" ? { x: rawSignal.x, y: rawSignal.y } : undefined, ); yield* Effect.sleep(750); const tabs = yield* SynchronizedRef.get(tabsRef); if (tabs.get(tabId)?.controller === "human") { yield* update(tabId, { controller: "none" }); } }); const recordingInput = (_event: unknown, input: unknown) => { if (!isRecordingInput(input)) return; return runFork( Effect.gen(function* () { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); if (tab?.webContentsId !== wc.id || !capture?.consumers.has("recording")) return; if (input.type === "key" && !capture.recordingInputOptions?.showKeyPresses) return; if (input.type === "pointer" && !capture.recordingInputOptions?.showMousePresses) return; const listeners = yield* Ref.get(recordingInputListenersRef); yield* Effect.forEach( listeners, (listener) => deliverEvent("recording-input", tabId, () => listener({ tabId, input })), { discard: true }, ); }), ); }; // Chromium splits one search across several updates (the ordinal and the // final count can land separately), so every update is forwarded. const foundInPage = (_event: unknown, result: Electron.Result): void => { runFork( emitFindResult({ tabId, activeMatchOrdinal: result.activeMatchOrdinal, matches: result.matches, }), ); }; const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; const mouseNavigate = (_event: unknown, payload?: unknown): void => { const direction = typeof payload === "object" && payload !== null && "direction" in payload ? (payload as { direction?: unknown }).direction : undefined; if (direction !== "back" && direction !== "forward") return; runFork( attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => { if (direction === "back") { if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); } else if (wc.navigationHistory.canGoForward()) { wc.navigationHistory.goForward(); } }).pipe(Effect.ignore), ); }; // Every guest of a focused window reports focus; GuestInput handles guests without a focused frame. const holdsKeyboardFocus = (contents: Electron.WebContents) => contents.isFocused() && contents.focusedFrame !== null; const syncMenuShortcuts = (contents: Electron.WebContents, input: Electron.Input): void => { if (input.type !== "keyDown") return; contents.setIgnoreMenuShortcuts( !isPreviewEditingShortcut(input, hostPlatform) || !holdsKeyboardFocus(contents), ); }; // Popups inherit Electron's unrestricted window handler; block nested popups to bound the chain. const windowCreated = (window: Electron.BrowserWindow): void => { window.webContents.setIgnoreMenuShortcuts(true); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); window.webContents.on("before-input-event", (_event, input) => { syncMenuShortcuts(window.webContents, input); }); }; const findForwardedShortcut = (keys: Parameters[0]) => !( hostPlatform !== "darwin" && keys.ctrlKey && keys.altKey && /^(?:[^a-zA-Z0-9]|Dead)$/u.test(keys.key) ) && forwardedShortcuts.find(({ shortcut }) => matchesKeybindingShortcut( keys, shortcut, hostPlatform === "darwin" ? "MacIntel" : hostPlatform, ), ); const beforeInput = (event: Electron.Event, input: Electron.Input): void => { syncMenuShortcuts(wc, input); const host = wc.hostWebContents; const forwarded = input.type === "keyDown" && !input.isComposing && host && !host.isDestroyed() && holdsKeyboardFocus(wc) && findForwardedShortcut({ key: input.key, code: input.code, metaKey: input.meta, ctrlKey: input.control, shiftKey: input.shift, altKey: input.alt, }); if (forwarded && host) { // The page keeps these keys. Only the main-frame preload can report one it // left unhandled, so a focused iframe (e.g. Google Docs' input) keeps its own find. if (PAGE_FIRST_FORWARDED_COMMANDS.has(forwarded.command)) return; event.preventDefault(); if (!input.isAutoRepeat) { if ( forwarded.command === "workspace.focusNext" || forwarded.command === "workspace.focusPrevious" || forwarded.command === "preview.focusUrl" ) { // DOM focus in the renderer cannot by itself release the guest's native keyboard focus. host.focus(); } host.send(MENU_ACTION_CHANNEL, forwarded.command); } return; } if (isPreviewRefreshShortcut(input, hostPlatform)) { event.preventDefault(); runFork( attempt({ operation: "shortcut.refresh", tabId, webContentsId: wc.id }, () => wc.reload(), ).pipe(Effect.ignore), ); return; } }; const pageUnhandledShortcut = (_event: unknown, rawKeys?: unknown): void => { const host = wc.hostWebContents; if (!host || host.isDestroyed() || !holdsKeyboardFocus(wc)) return; const keys = decodePageShortcutKeys(rawKeys); if (Option.isNone(keys)) return; const forwarded = findForwardedShortcut(keys.value); if (!forwarded || !PAGE_FIRST_FORWARDED_COMMANDS.has(forwarded.command)) return; host.send(MENU_ACTION_CHANNEL, forwarded.command); }; yield* Scope.addFinalizer( scope, attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { cancelFaviconCapture(); wc.off("did-start-navigation", navigationStarted); wc.off("did-navigate", syncNavigation); wc.off("did-navigate-in-page", syncInPageNavigation); wc.off("page-title-updated", sync); wc.off("page-favicon-updated", faviconUpdated as never); wc.off("did-start-loading", sync); wc.off("did-stop-loading", sync); wc.off("dom-ready", restoreRecordingCursor); wc.off("did-fail-load", failed as never); wc.off("audio-state-changed", audioStateChanged); wc.off("found-in-page", foundInPage as never); wc.off("did-create-window", windowCreated); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.ipc.off(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys(); }).pipe(Effect.ignore), ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { wc.setIgnoreMenuShortcuts(true); wc.on("did-start-navigation", navigationStarted); wc.on("did-navigate", syncNavigation); wc.on("did-navigate-in-page", syncInPageNavigation); wc.on("page-title-updated", sync); wc.on("page-favicon-updated", faviconUpdated as never); wc.on("did-start-loading", sync); wc.on("did-stop-loading", sync); wc.on("dom-ready", restoreRecordingCursor); wc.on("did-fail-load", failed as never); wc.on("audio-state-changed", audioStateChanged); wc.on("found-in-page", foundInPage as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.ipc.on(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys = passkeys.attachGuest(wc); wc.setWindowOpenHandler((details) => { const action = previewWindowOpenAction(details); if (action === "popup") { return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS }; } if (action === "deny") return { action: "deny" }; if (action === "new-tab") { runFork( emitOpenLink({ tabId, url: details.url, background: details.disposition === "background-tab", }), ); return { action: "deny" }; } runFork( attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => wc.loadURL(details.url), ).pipe(Effect.ignore), ); return { action: "deny" }; }); wc.on("did-create-window", windowCreated); wc.on("before-input-event", beforeInput); }); yield* Ref.update(attachedRef, (attached) => replaceMap(attached, (copy) => { copy.set(wc.id, { attachmentId, cancelFaviconCapture, scope, webContents: wc }); }), ); }); yield* install().pipe(Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore))); }); const setMainWindow = Effect.fn("PreviewManager.setMainWindow")(function* ( window: BrowserWindow, ) { if (mainWindowCleanupFiber) { yield* Fiber.join(mainWindowCleanupFiber); mainWindowCleanupFiber = undefined; } yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => Effect.gen(function* () { // Cleanup or lock acquisition can outlive the replacement window. if (window.isDestroyed()) return [undefined, sessions] as const; if (sessions.size > 0) { yield* setWindowBackgroundThrottling(window, false); } yield* Ref.set(mainWindowRef, Option.some(window)); currentMainWindow = window; frameCaptureWindowOpen = true; window.once("closed", () => { if (currentMainWindow !== window) return; currentMainWindow = undefined; frameCaptureWindowOpen = false; mainWindowCleanupFiber = runFork( Effect.all([closeAllPictureInPicture(), stopAllRecordings()], { concurrency: "unbounded", discard: true, }).pipe(Effect.ignore), ); }); return [undefined, sessions] as const; }), ).pipe(Effect.uninterruptible); }); const createTabUnlocked = Effect.fn("PreviewManager.createTabUnlocked")(function* ( tabId: string, defaults?: DesktopPreviewTabDefaults, ) { const updatedAt = yield* currentIso; const result = yield* SynchronizedRef.modify( tabsRef, ( tabs, ): readonly [ { readonly state: PreviewTabState; readonly created: boolean }, ReadonlyMap, ] => { const existing = tabs.get(tabId); if (existing) return [{ state: existing, created: false }, tabs] as const; const initial: PreviewTabState = { tabId, webContentsId: null, navStatus: { kind: "Idle" }, canGoBack: false, canGoForward: false, zoomFactor: normalizeZoomFactor(defaults?.zoomFactor), pictureInPicture: false, colorScheme: defaults?.colorScheme ?? "system", ...(defaults?.serverTab === undefined ? {} : { serverTab: defaults.serverTab }), audioMuted: false, audible: false, controller: "none", capturing: false, updatedAt, }; return [ { state: initial, created: true }, replaceMap(tabs, (copy) => { copy.set(tabId, initial); }), ] as const; }, ); if (result.created) { tabLifecycleGenerations.set(tabId, (tabLifecycleGenerations.get(tabId) ?? 0) + 1); } yield* emit(tabId, result.state); return result.state; }); const createTab = Effect.fn("PreviewManager.createTab")(function* ( tabId: string, defaults?: DesktopPreviewTabDefaults, ) { return yield* withTabLifecycleLock(tabId, createTabUnlocked(tabId, defaults)); }); const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) { if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return; clearPendingRecording(tabId); annotationSendEnabled.delete(tabId); yield* Effect.all( [ cancelPickElement(tabId), closePictureInPicture(tabId), stopFrameCapture(tabId, "recording"), ], { concurrency: 3, discard: true, }, ); const tab = yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); if (!current) return [Option.none(), tabs] as const; return [ Option.some(current), replaceMap(tabs, (copy) => { copy.delete(tabId); }), ] as const; }); if (Option.isNone(tab)) return; const closedTab = tab.value; if (closedTab.serverTab) browserHost.setShown(closedTab.serverTab, undefined); if (closedTab.webContentsId != null) { yield* Effect.all( [ detachControlSession(closedTab.webContentsId, closedTab.serverTab), detachListeners(closedTab.webContentsId), ], { concurrency: 2, discard: true }, ); } const updatedAt = yield* currentIso; const closed: PreviewTabState = { ...closedTab, webContentsId: null, navStatus: { kind: "Idle" }, canGoBack: false, canGoForward: false, zoomFactor: DEFAULT_ZOOM_FACTOR, pictureInPicture: false, colorScheme: "system", audioMuted: false, audible: false, controller: "none", capturing: false, updatedAt, }; yield* emit(tabId, closed); }); const closeTab = Effect.fn("PreviewManager.closeTab")(function* (tabId: string) { const claimed = yield* Ref.modify(closingTabIdsRef, (closingTabIds) => { if (closingTabIds.has(tabId)) return [false, closingTabIds] as const; return [true, new Set([...closingTabIds, tabId])] as const; }); if (!claimed) return; return yield* withTabLifecycleLock(tabId, closeTabUnlocked(tabId)).pipe( Effect.ensuring( Ref.update(closingTabIdsRef, (closingTabIds) => { if (!closingTabIds.has(tabId)) return closingTabIds; const next = new Set(closingTabIds); next.delete(tabId); return next; }), ), ); }); const registerWebviewUnlocked = Effect.fn("PreviewManager.registerWebviewUnlocked")(function* ( tabId: string, webContentsId: number, expectedGeneration: number | undefined, ) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if ( !tab || tabLifecycleGenerations.get(tabId) !== expectedGeneration || (yield* Ref.get(closingTabIdsRef)).has(tabId) ) { return yield* new PreviewTabNotFoundError({ tabId }); } const wc = webContents.fromId(webContentsId); const mainWindow = yield* Ref.get(mainWindowRef); if ( !wc || wc.isDestroyed() || wc.getType() !== "webview" || (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents) ) { return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId }); } yield* rendererHistory.register(wc, { surface: "preview", tabId }); const attached = yield* Ref.get(attachedRef); const annotationTheme = yield* Ref.get(annotationThemeRef); const currentAttachment = attached.get(webContentsId); yield* keepFrameCaptureWebContentsUnthrottled(tabId, wc); if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { yield* assertTabZoom(tabId); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => { wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme); wc.send(ANNOTATION_SEND_ENABLED_CHANNEL, annotationSendEnabled.get(tabId) === true); }); return; } const replacedWebContentsId = tab.webContentsId != null && (tab.webContentsId !== webContentsId || currentAttachment?.webContents !== wc) ? tab.webContentsId : null; if (replacedWebContentsId !== null) { clearPendingRecording(tabId); yield* Effect.all( [ detachControlSession(replacedWebContentsId), detachListeners(replacedWebContentsId), cancelPickElement(tabId), ], { concurrency: 3, discard: true }, ); } const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if ( !currentTab || tabLifecycleGenerations.get(tabId) !== expectedGeneration || (yield* Ref.get(closingTabIdsRef)).has(tabId) ) { return yield* new PreviewTabNotFoundError({ tabId }); } // Restore the tab's zoom before publishing the guest so it never paints at inherited app zoom. yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => wc.setZoomFactor(currentTab.zoomFactor), ); // Replacement guests start unmuted; restore mute before publishing them. yield* attempt({ operation: "registerWebview.restoreAudioMuted", tabId, webContentsId }, () => wc.setAudioMuted(currentTab.audioMuted), ); yield* attachListeners(tabId, wc); const readAudible = attempt( { operation: "registerWebview.readAudible", tabId, webContentsId }, () => wc.isCurrentlyAudible(), ).pipe(Effect.orElseSucceed(() => false)); const attachedAudible = yield* readAudible; const registeredAt = yield* currentIso; const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) => Effect.gen(function* () { const current = tabs.get(tabId); if ( !current || tabLifecycleGenerations.get(tabId) !== expectedGeneration || (yield* Ref.get(closingTabIdsRef)).has(tabId) ) { return [ Option.none<{ readonly state: PreviewTabState; readonly pendingUrl: string | null }>(), tabs, ] as const; } const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; const { favicon: _favicon, ...currentWithoutFavicon } = current; const next: PreviewTabState = { ...currentWithoutFavicon, webContentsId, navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, canGoBack: wc.navigationHistory.canGoBack(), canGoForward: wc.navigationHistory.canGoForward(), audible: attachedAudible, updatedAt: registeredAt, }; return [ Option.some({ state: next, pendingUrl, }), replaceMap(tabs, (copy) => { copy.set(tabId, next); }), ] as const; }), ); if (Option.isNone(registration)) { yield* Effect.all([detachControlSession(webContentsId), detachListeners(webContentsId)], { concurrency: 2, discard: true, }); return yield* new PreviewTabNotFoundError({ tabId }); } const { state: registered, pendingUrl } = registration.value; // Concurrent actions targeted the previous guest; reconcile this one with committed zoom and mute. yield* assertTabZoom(tabId); yield* assertTabAudioMuted(tabId).pipe(Effect.ignore); runFork(restoreControlSession(tabId, wc)); yield* emitIfCurrent(tabId, registered); // Ownership checks dropped pre-attach audibility events; reconcile through the event path. yield* syncTabAudible(tabId, wc, yield* readAudible); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => { wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme); wc.send(ANNOTATION_SEND_ENABLED_CHANNEL, annotationSendEnabled.get(tabId) === true); }); const latestNavStatus = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.navStatus; if ( pendingUrl && latestNavStatus?.kind === "Loading" && latestNavStatus.url === pendingUrl && wc.getURL() !== pendingUrl ) { runFork( attemptPromise({ operation: "registerWebview.loadPendingUrl", tabId, webContentsId }, () => wc.loadURL(pendingUrl), ).pipe(Effect.ignore), ); } }); const registerWebview = Effect.fn("PreviewManager.registerWebview")(function* ( tabId: string, webContentsId: number, ) { const expectedGeneration = tabLifecycleGenerations.get(tabId); return yield* withTabLifecycleLock( tabId, registerWebviewUnlocked(tabId, webContentsId, expectedGeneration), ); }); // URL tabs can paint before registerWebview; set the background at attach time. const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* ( wc: Electron.WebContents, ) { yield* rendererHistory.register(wc, { surface: "preview" }); const webContentsId = wc.id; wc.once("destroyed", () => { runFork(hideDestroyedGuest(webContentsId)); runFork(detachControlSession(webContentsId)); }); yield* ensureControlSession(wc).pipe( Effect.catchCause((cause) => Effect.logDebug("Preview webview control session was not opened on attach.", { webContentsId, cause, }), ), ); }); const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => normalizePreviewUrl(rawUrl), ); const updatedAt = yield* currentIso; const pending = yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); const next: PreviewTabState = { tabId, webContentsId: current?.webContentsId ?? null, navStatus: { kind: "Loading", url, title: current?.navStatus.kind === "Idle" || !current ? "" : current.navStatus.title, }, canGoBack: current?.canGoBack ?? false, canGoForward: current?.canGoForward ?? false, zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, pictureInPicture: current?.pictureInPicture ?? false, colorScheme: current?.colorScheme ?? "system", // Keep audibility until Chromium replaces the document; clearing it here can lose the final transition. audioMuted: current?.audioMuted ?? false, audible: current?.audible ?? false, controller: current?.controller ?? "none", capturing: current?.capturing ?? false, ...(current?.favicon ? { favicon: current.favicon } : {}), ...(current?.serverTab ? { serverTab: current.serverTab } : {}), updatedAt, }; return [ next, replaceMap(tabs, (copy) => { copy.set(tabId, next); }), ] as const; }); yield* emitIfCurrent(tabId, pending); if (pending.webContentsId == null) return; const webContentsId = pending.webContentsId; const wc = webContents.fromId(webContentsId); if (!wc || wc.isDestroyed()) { const expectedAttachment = (yield* Ref.get(attachedRef)).get(webContentsId); yield* withTabLifecycleLock( tabId, Effect.gen(function* () { const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); const currentAttachment = (yield* Ref.get(attachedRef)).get(webContentsId); const currentWebContents = webContents.fromId(webContentsId); if ( currentTab?.webContentsId !== webContentsId || currentAttachment !== expectedAttachment || (currentWebContents && !currentWebContents.isDestroyed()) ) { return; } yield* Effect.all( [ detachControlSession(webContentsId), detachListeners(webContentsId), cancelPickElement(tabId), ], { concurrency: 3, discard: true }, ); const detached = yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); if (current?.webContentsId !== webContentsId) { return [Option.none(), tabs] as const; } const { favicon: _favicon, ...currentWithoutFavicon } = current; const next: PreviewTabState = { ...currentWithoutFavicon, webContentsId: null }; return [ Option.some(next), replaceMap(tabs, (copy) => { copy.set(tabId, next); }), ] as const; }); if (Option.isSome(detached)) yield* emitIfCurrent(tabId, detached.value); }), ); return; } if (wc.getURL() === url) { yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () => wc.reload(), ); return; } let unloadPrevented = false; const onPreventUnload = () => { unloadPrevented = true; }; yield* attemptPromise({ operation: "navigate.loadURL", tabId, webContentsId: wc.id }, () => { wc.on("will-prevent-unload", onPreventUnload); return wc.loadURL(url); }).pipe( // Blocked unloads and superseding navigations both reject with ERR_ABORTED. Effect.catchIf( (error) => !unloadPrevented && Predicate.hasProperty(error.cause, "errno") && error.cause.errno === -3, () => Effect.void, ), Effect.ensuring(Effect.sync(() => wc.off("will-prevent-unload", onPreventUnload))), ); }); const withWebContents = Effect.fn("PreviewManager.withWebContents")(function* ( operation: string, tabId: string, use: (wc: Electron.WebContents) => void, ) { const wc = yield* requireWebContents(tabId); yield* attempt({ operation, tabId, webContentsId: wc.id }, () => use(wc)); }); const goBack = (tabId: string) => withWebContents("goBack", tabId, (wc) => { if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); }); const goForward = (tabId: string) => withWebContents("goForward", tabId, (wc) => { if (wc.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); }); const refresh = (tabId: string) => withWebContents("refresh", tabId, (wc) => wc.reload()); const hardReload = (tabId: string) => withWebContents("hardReload", tabId, (wc) => wc.reloadIgnoringCache()); const findInPage = ( tabId: string, text: string, options: { readonly forward: boolean; readonly newSession: boolean }, ) => withWebContents("findInPage", tabId, (wc) => { // Electron's `findNext` means "begin a new session", not "go to the next match". wc.findInPage(text, { forward: options.forward, findNext: options.newSession }); }); const stopFindInPage = (tabId: string) => withWebContents("stopFindInPage", tabId, (wc) => wc.stopFindInPage("clearSelection")).pipe( Effect.catchTags({ PreviewTabNotFoundError: () => Effect.void, PreviewWebContentsNotFoundError: () => Effect.void, }), ); const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) { const wc = yield* requireWebContents(tabId); if (wc.isDevToolsOpened()) { yield* attempt({ operation: "openDevTools.focus", tabId, webContentsId: wc.id }, () => wc.devToolsWebContents?.focus(), ); return; } yield* detachControlSession(wc.id); yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => { wc.once("devtools-closed", () => { if (!wc.isDestroyed()) runFork(restoreControlSession(tabId, wc)); }); wc.openDevTools({ mode: "detach" }); }); }); const setAnnotationTheme = Effect.fn("PreviewManager.setAnnotationTheme")(function* ( theme: DesktopPreviewAnnotationTheme, ) { yield* Ref.set(annotationThemeRef, theme); const tabs = yield* SynchronizedRef.get(tabsRef); yield* Effect.forEach( tabs.values(), (tab) => { if (tab.webContentsId == null) return Effect.void; const wc = webContents.fromId(tab.webContentsId); return !wc || wc.isDestroyed() ? Effect.void : attempt( { operation: "setAnnotationTheme", tabId: tab.tabId, webContentsId: tab.webContentsId, }, () => wc.send(ANNOTATION_THEME_CHANNEL, theme), ).pipe(Effect.ignore); }, { discard: true }, ); }); const setAnnotationSendEnabled = Effect.fn("PreviewManager.setAnnotationSendEnabled")(function* ( tabId: string, enabled: boolean, ) { const wc = yield* requireWebContents(tabId); yield* attempt({ operation: "setAnnotationSendEnabled", tabId, webContentsId: wc.id }, () => { annotationSendEnabled.set(tabId, enabled); wc.send(ANNOTATION_SEND_ENABLED_CHANNEL, enabled); }); }); // Voice state reaches a guest only while its pick session, by id, still owns the tab. const updateAnnotationVoice = Effect.fn("PreviewManager.updateAnnotationVoice")(function* ( state: DesktopPreviewAnnotationVoiceState, ) { const sessions = yield* Ref.get(pickSessionsRef); if (sessions.get(state.tabId)?.voiceSessionId !== state.sessionId) return; const wc = yield* requireWebContents(state.tabId); yield* attempt({ operation: "annotationVoice.update", tabId: state.tabId }, () => wc.send(ANNOTATION_VOICE_STATE_CHANNEL, state), ); }); const pickElement = Effect.fn("PreviewManager.pickElement")(function* ( tabId: string, voice?: DesktopPreviewAnnotationVoiceConfig, ) { const wc = yield* requireWebContents(tabId); yield* cancelPickElement(tabId); const annotationTheme = yield* Ref.get(annotationThemeRef); return yield* Effect.callback( (resume) => { const session: PickSession = { voiceSessionId: voice?.sessionId, cancel: Effect.suspend(() => cancelPickSession()), }; const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); wc.ipc.removeListener(ANNOTATION_VOICE_EVENT_CHANNEL, onVoiceEvent); wc.off("destroyed", onDestroyed); wc.off("did-start-navigation", onNavigated); }).pipe(Effect.ignore); // A newer session may already own the slot when this one is cancelled. yield* Ref.update(pickSessionsRef, (sessions) => sessions.get(tabId) === session ? replaceMap(sessions, (copy) => { copy.delete(tabId); }) : sessions, ); }); let settled = false; const claimSettle = (): boolean => { if (settled) return false; settled = true; return true; }; const finishPick = Effect.fn("PreviewManager.finishPickElement")(function* ( payload: PreviewAnnotationSubmissionResult | null, ) { yield* cleanup(); resume(Effect.succeed(payload)); }); const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* ( payload: PreviewAnnotationSubmissionResult | null, ) { if (!claimSettle()) return; yield* finishPick(payload); }); const settle = (payload: PreviewAnnotationSubmissionResult | null) => { runFork(settlePick(payload)); }; const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () { if (!claimSettle()) return; yield* cleanup(); const tabs = yield* SynchronizedRef.get(tabsRef); const activeTab = tabs.get(tabId); if (activeTab?.webContentsId != null) { const activeWc = webContents.fromId(activeTab.webContentsId); if (activeWc && !activeWc.isDestroyed()) { yield* attempt( { operation: "cancelPickElement", tabId, webContentsId: activeWc.id, }, () => activeWc.send(CANCEL_PICK_CHANNEL), ).pipe(Effect.ignore); } } resume(Effect.succeed(null)); }); const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { const payload = args[0]; if (!isPreviewAnnotationPayload(payload)) { settle(null); return; } const cropRect = normalizeCaptureRect(args[1]); const submission = args[2] === "send" && annotationSendEnabled.get(tabId) === true ? "send" : "attach"; const devicePixelRatio = typeof args[3] === "number" ? args[3] : 1; const targetRects = [ ...payload.elements.map((target) => target.rect), ...payload.regions.map((target) => target.rect), ...payload.strokes.map((target) => target.bounds), ]; runFork( capturePageWithRetry( { operation: "captureAnnotationScreenshot", tabId, webContentsId: wc.id }, tabId, wc, ).pipe( Effect.map((image) => cropAnnotationScreenshot(image, cropRect, devicePixelRatio, targetRects), ), Effect.tapError((error) => Effect.logWarning("preview annotation screenshot failed").pipe( Effect.annotateLogs({ tabId, webContentsId: wc.id, error: error.message }), ), ), Effect.withSpan("PreviewManager.captureAnnotationScreenshot"), Effect.match({ onFailure: (): PreviewAnnotationSubmissionResult => ({ annotation: payload, submission, screenshotFailed: true, }), onSuccess: (screenshot): PreviewAnnotationSubmissionResult => screenshot === null ? { annotation: payload, submission, screenshotFailed: true } : { annotation: { ...payload, screenshot }, submission }, }), Effect.flatMap((result) => { // Late captures must not signal an overlay that may now belong to a newer pick. if (!claimSettle()) return Effect.void; return attempt( { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id }, () => { if (!wc.isDestroyed()) wc.send(ANNOTATION_CAPTURED_CHANNEL); }, ).pipe(Effect.ignore, Effect.andThen(finishPick(result))); }), ), ); }; // Only this guest's listener sees these; the renderer still checks the session id. const onVoiceEvent = (_event: Electron.IpcMainEvent, value: unknown) => { const decoded = decodeAnnotationVoiceEvent(value); if (settled || !voice || Option.isNone(decoded)) return; if (decoded.value.sessionId !== voice.sessionId) return; runFork( Effect.gen(function* () { const window = yield* Ref.get(mainWindowRef); if (Option.isNone(window) || window.value.isDestroyed()) return; yield* attempt({ operation: "annotationVoice.forward", tabId }, () => window.value.webContents.send( PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, tabId, decoded.value, ), ).pipe(Effect.ignore); }), ); }; const onDestroyed = () => settle(null); const onNavigated = ( event: Electron.Event, ) => { if (event.isMainFrame) settle(null); }; const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { // Atomically swap/cancel overlapping picks so another pick cannot claim a transient empty slot. const replaced = yield* Ref.modify(pickSessionsRef, (sessions) => [ sessions.get(tabId) ?? null, replaceMap(sessions, (copy) => { copy.set(tabId, session); }), ]); if (replaced) yield* replaced.cancel; // A newer pick may cancel during teardown; do not leak listeners or start an abandoned overlay. if (settled) return; yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); if (voice) wc.ipc.on(ANNOTATION_VOICE_EVENT_CHANNEL, onVoiceEvent); wc.once("destroyed", onDestroyed); wc.on("did-start-navigation", onNavigated); if (!wc.isFocused()) wc.focus(); wc.send( START_PICK_CHANNEL, annotationTheme, annotationSendEnabled.get(tabId) === true, voice, ); }); }); runFork( registerPickElement().pipe( Effect.catch((error: PreviewManagerError) => { if (!claimSettle()) return Effect.void; resume(Effect.fail(error)); return cleanup(); }), ), ); return session.cancel; }, ); }); const reapplyZoom = Effect.fn("PreviewManager.reapplyZoom")(function* () { const tabIds = Array.from((yield* SynchronizedRef.get(tabsRef)).keys()); yield* Effect.forEach(tabIds, assertTabZoom, { discard: true }); }); const applyZoom = Effect.fn("PreviewManager.applyZoom")(function* ( tabId: string, transform: (current: number) => number, ) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab) return; const next = transform(tab.zoomFactor); if (Math.abs(next - tab.zoomFactor) < ZOOM_EPSILON) return; if (tab.webContentsId != null) { const wc = webContents.fromId(tab.webContentsId); if (wc && !wc.isDestroyed()) { yield* attempt({ operation: "applyZoom", tabId, webContentsId: wc.id }, () => wc.setZoomFactor(next), ); } } yield* update(tabId, { zoomFactor: next }); }); // Media emulation belongs to the debugger session and must be restored after every reattach. const applyColorScheme = Effect.fn("PreviewManager.applyColorScheme")(function* ( tabId: string, wc: Electron.WebContents, colorScheme: DesktopPreviewColorScheme, ) { const control = yield* ensureControlSession(wc); yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => control.debugger.sendCommand("Emulation.setEmulatedMedia", { features: [ { name: "prefers-color-scheme", value: colorScheme === "system" ? "" : colorScheme, }, ], }), ); }); // The scheme is read after the session attaches so a concurrent setColorScheme is not // overwritten with a stale snapshot. const restoreControlSession = Effect.fn("PreviewManager.restoreControlSession")( function* (tabId: string, wc: Electron.WebContents) { const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (beforeAttach?.webContentsId !== wc.id) return; const control = yield* ensureControlSession(wc); const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (afterAttach?.webContentsId !== wc.id) { yield* detachControlSession(wc.id); return; } if (afterAttach.serverTab) { yield* listenToBrowserHost; browserHost.attach(afterAttach.serverTab, { webContents: wc, debugger: control.debugger }); } if (afterAttach.colorScheme !== "system") { yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => control.debugger.sendCommand("Emulation.setEmulatedMedia", { features: [ { name: "prefers-color-scheme", value: afterAttach.colorScheme, }, ], }), ); } }, (effect, _tabId, _wc) => effect.pipe(Effect.ignore), ); // Only the session that saw the detach is replaced: by the time the event is // handled the guest may already hold a newer one, which must stay. const recoverDetachedControlSession = Effect.fn("PreviewManager.recoverDetachedControlSession")( function* (wc: Electron.WebContents, detached: BrowserControlSession) { const removed = yield* SynchronizedRef.modify(controlSessionsRef, (sessions) => sessions.get(wc.id) === detached ? ([ true, replaceMap(sessions, (copy) => { copy.delete(wc.id); }), ] as const) : ([false, sessions] as const), ); if (!removed) return; yield* releaseControlSession(detached); if (wc.isDestroyed()) return; const tabId = yield* tabIdForWebContents(wc.id); if (tabId === null) return; yield* restoreControlSession(tabId, wc); }, (recover) => Effect.ignore(recover), ); const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* ( tabId: string, colorScheme: DesktopPreviewColorScheme, ) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab) { return yield* new PreviewTabNotFoundError({ tabId }); } if (tab.colorScheme !== colorScheme) { yield* update(tabId, { colorScheme }); } // registerWebview may swap guests during the update; apply to the current one. const webContentsId = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.webContentsId; if (webContentsId == null) return; const wc = webContents.fromId(webContentsId); if (!wc || wc.isDestroyed()) return; yield* applyColorScheme(tabId, wc, colorScheme); }); const setShown = Effect.fn("PreviewManager.setShown")(function* (tabId: string, shown: boolean) { const serverTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.serverTab; if (serverTab) browserHost.setShown(serverTab, shown); }); // A window reload or guest crash destroys the guest without the renderer reporting it hidden. // The renderer reports again once a new guest registers. const hideDestroyedGuest = Effect.fnUntraced(function* (webContentsId: number) { for (const tab of (yield* SynchronizedRef.get(tabsRef)).values()) { if (tab.webContentsId === webContentsId && tab.serverTab) { browserHost.setShown(tab.serverTab, false); } } }); const setAudioMuted = Effect.fn("PreviewManager.setAudioMuted")(function* ( tabId: string, audioMuted: boolean, ) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab) { return yield* new PreviewTabNotFoundError({ tabId }); } // Serialize commit/apply, then read committed mute so concurrent toggles cannot reach Chromium out of order. yield* withTabLifecycleLock( tabId, Effect.gen(function* () { const previous = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.audioMuted; const committed = previous !== undefined && previous !== audioMuted; if (committed) { yield* update(tabId, { audioMuted }); } // Roll back rejected mute commits so the UI reflects actual playback. yield* assertTabAudioMuted(tabId).pipe( Effect.tapError(() => committed ? update(tabId, { audioMuted: previous }) : Effect.void, ), ); }), ); }); const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* ( tabId: string, ) { const wc = yield* requireWebContents(tabId); const [createdAt, millis, image] = yield* Effect.all([ currentIso, currentMillis, capturePageWithRetry( { operation: "captureScreenshot.capturePage", tabId, webContentsId: wc.id, }, tabId, wc, ), ]); const uuid = yield* random.uuidV4(); const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}-${uuid.slice(0, 8)}`; const artifactPath = path.join(resolvedArtifactDirectory, `${id}.png`); const data = image.toPNG(); yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe( Effect.mapError( (cause) => new PreviewOperationError({ operation: "captureScreenshot.makeDirectory", tabId, webContentsId: wc.id, artifactPath, cause, }), ), ); yield* fileSystem.writeFile(artifactPath, data).pipe( Effect.mapError( (cause) => new PreviewOperationError({ operation: "captureScreenshot.writeFile", tabId, webContentsId: wc.id, artifactPath, cause, }), ), ); return { id, tabId, path: artifactPath, mimeType: "image/png" as const, sizeBytes: data.byteLength, createdAt, }; }); const capturePreviewFrame = Effect.fn("PreviewManager.capturePreviewFrame")(function* ( tabId: string, ) { const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); if (!captureSession?.consumers.has("picture-in-picture") || captureSession.scope === null) return; const wc = yield* requireWebContents(tabId); const captureContext = { operation: "frameCapture.capturePage", tabId, webContentsId: wc.id }; const image = yield* attemptPromise(captureContext, () => wc.capturePage()).pipe( Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS), Effect.catchTags({ TimeoutError: (cause) => Effect.fail(new PreviewOperationError({ ...captureContext, cause })), }), ); const currentCaptureSession = yield* Effect.all( [SynchronizedRef.get(frameCaptureSessionsRef), SynchronizedRef.get(tabsRef)], { concurrency: 2 }, ).pipe( Effect.map(([captureSessions, tabs]) => { const current = captureSessions.get(tabId); return current?.scope === captureSession.scope && tabs.get(tabId)?.webContentsId === wc.id && !wc.isDestroyed() ? current : undefined; }), ); if (!currentCaptureSession) return; const size = yield* attempt( { operation: "frameCapture.measureFrame", tabId, webContentsId: wc.id, }, () => image.getSize(), ); if ( !Number.isFinite(size.width) || !Number.isFinite(size.height) || size.width <= 0 || size.height <= 0 ) { return; } const frameSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); if (frameSession?.scope !== captureSession.scope) return; // Fingerprint before encoding so unchanged frames skip JPEG, base64, and IPC; unreadable frames must still be sent. const fingerprint = fingerprintCapturedFrame(image, size); const pictureInPicture = frameSession.consumers.has("picture-in-picture") && (fingerprint === null || frameSession.lastPictureInPictureFingerprint !== fingerprint); if (!pictureInPicture) return; const encoded = yield* attempt( { operation: "frameCapture.encodeFrame", tabId, webContentsId: wc.id, }, () => image.toJPEG(PICTURE_IN_PICTURE_JPEG_QUALITY), ); const receivedAt = yield* currentIso; const frame: DesktopPreviewRecordingFrame = { tabId, data: encoded.toString("base64"), width: size.width, height: size.height, receivedAt, }; const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( tabId, )?.window; if (!pictureInPictureWindow || pictureInPictureWindow.isDestroyed()) return; yield* Effect.gen(function* () { const previousAspectRatio = (yield* Ref.get(pictureInPictureAspectRatiosRef)).get(tabId); const aspectRatio = frame.width / frame.height; if ( previousAspectRatio === undefined || Math.abs(previousAspectRatio - aspectRatio) > PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON ) { yield* attempt( { operation: "pictureInPicture.setAspectRatio", tabId, webContentsId: wc.id, }, () => { const contentSize = fitPictureInPictureContentSize( pictureInPictureWindow.getContentSize(), aspectRatio, ); pictureInPictureWindow.setAspectRatio(0); pictureInPictureWindow.setContentSize(contentSize[0], contentSize[1], false); pictureInPictureWindow.setAspectRatio(aspectRatio); }, ); yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => replaceMap(aspectRatios, (copy) => { copy.set(tabId, aspectRatio); }), ); } yield* attempt( { operation: "pictureInPicture.deliverFrame", tabId, webContentsId: wc.id, }, () => { pictureInPictureWindow.webContents.send(PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, frame); }, ); yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => { if (sessions.get(tabId) !== frameSession) return sessions; return replaceMap(sessions, (copy) => { copy.set(tabId, { ...frameSession, lastPictureInPictureFingerprint: fingerprint, }); }); }); }).pipe( Effect.catch((error) => Effect.logWarning("Picture-in-picture frame delivery failed.", { tabId, error, }), ), ); }); const startFrameCapture = Effect.fn("PreviewManager.startFrameCapture")(function* ( tabId: string, consumer: FrameCaptureConsumer, ) { const captureNextFrame = Effect.sleep(PICTURE_IN_PICTURE_FRAME_INTERVAL_MS).pipe( Effect.andThen(capturePreviewFrame(tabId)), Effect.catch((error) => Effect.logWarning("Background preview frame capture failed.", { tabId, error, }), ), ); const captureInitialFrame = yield* SynchronizedRef.modifyEffect( frameCaptureSessionsRef, (sessions) => { return Effect.gen(function* () { if (!frameCaptureWindowOpen) { return yield* new PreviewMainWindowClosedError({ tabId }); } const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) { return yield* new PreviewTabNotFoundError({ tabId }); } const wc = yield* requireWebContents(tabId); const current = sessions.get(tabId); if (current) { if (current.consumers.has(consumer)) { return [false, sessions] as const; } if (!current.unthrottledWebContentsIds.has(wc.id)) { yield* setFrameCaptureWebContentsBackgroundThrottling(wc, false); } let scope = current.scope; if (consumer === "picture-in-picture" && scope === null) { scope = yield* Scope.fork(parentScope, "sequential"); yield* Effect.forkIn(Effect.forever(captureNextFrame), scope); } return [ consumer === "picture-in-picture", replaceMap(sessions, (copy) => { copy.set(tabId, { ...current, scope, consumers: new Set([...current.consumers, consumer]), unthrottledWebContentsIds: new Set([...current.unthrottledWebContentsIds, wc.id]), }); }), ] as const; } if (sessions.size === 0) { yield* setFrameCaptureBackgroundThrottling(false); } yield* setFrameCaptureWebContentsBackgroundThrottling(wc, false).pipe( Effect.onError(() => sessions.size === 0 ? setFrameCaptureBackgroundThrottling(true).pipe(Effect.ignore) : Effect.void, ), ); const scope = consumer === "picture-in-picture" ? yield* Scope.fork(parentScope, "sequential") : null; if (scope !== null) { yield* Effect.forkIn(Effect.forever(captureNextFrame), scope); } return [ consumer === "picture-in-picture", replaceMap(sessions, (copy) => { copy.set(tabId, { scope, consumers: new Set([consumer]), unthrottledWebContentsIds: new Set([wc.id]), lastPictureInPictureFingerprint: null, }); }), ] as const; }); }, ).pipe(Effect.uninterruptible); if (!captureInitialFrame) return; yield* capturePreviewFrame(tabId).pipe( Effect.catch((error) => Effect.logWarning("Initial background preview frame was not ready; capture will retry.", { tabId, consumer, error, }), ), ); }); const releasePictureInPicture = Effect.fn("PreviewManager.releasePictureInPicture")(function* ( tabId: string, expectedSession: PictureInPictureSession, closeWindow: boolean, ) { const removed = yield* SynchronizedRef.modify(pictureInPictureSessionsRef, (sessions) => { if (sessions.get(tabId) !== expectedSession) { return [false, sessions] as const; } return [ true, replaceMap(sessions, (copy) => { copy.delete(tabId); }), ] as const; }); if (!removed) return; yield* Deferred.interrupt(expectedSession.ready); yield* Scope.close(expectedSession.initializationScope, Exit.void).pipe(Effect.ignore); yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => replaceMap(aspectRatios, (copy) => { copy.delete(tabId); }), ); yield* stopFrameCapture(tabId, "picture-in-picture"); const tabs = yield* SynchronizedRef.get(tabsRef); if (tabs.has(tabId)) { yield* update(tabId, { pictureInPicture: false }); } if (closeWindow && !expectedSession.window.isDestroyed()) { yield* attempt({ operation: "pictureInPicture.close", tabId }, () => expectedSession.window.close(), ).pipe(Effect.ignore); } }); const closePictureInPictureUnlocked = Effect.fn("PreviewManager.closePictureInPictureUnlocked")( function* (tabId: string) { const pictureInPictureSession = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( tabId, ); if (!pictureInPictureSession) { yield* stopFrameCapture(tabId, "picture-in-picture"); const tabs = yield* SynchronizedRef.get(tabsRef); if (tabs.has(tabId)) { yield* update(tabId, { pictureInPicture: false }); } return; } yield* releasePictureInPicture(tabId, pictureInPictureSession, true); }, ); const closePictureInPicture = Effect.fn("PreviewManager.closePictureInPicture")(function* ( tabId: string, ) { yield* pictureInPictureMutationSemaphore.withPermit(closePictureInPictureUnlocked(tabId)); }); const closeAllPictureInPicture = Effect.fn("PreviewManager.closeAllPictureInPicture")( function* () { const sessions = yield* SynchronizedRef.get(pictureInPictureSessionsRef); yield* Effect.forEach(sessions.keys(), closePictureInPicture, { concurrency: "unbounded", discard: true, }); }, ); const openPictureInPicture = Effect.fn("PreviewManager.openPictureInPicture")(function* ( tabId: string, ) { const claim = yield* pictureInPictureMutationSemaphore.withPermit( Effect.gen(function* () { const existing = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId); if (existing && !existing.window.isDestroyed()) { return { kind: "existing" as const, session: existing }; } if (existing) { yield* releasePictureInPicture(tabId, existing, false); } const wc = yield* requireWebContents(tabId); const title = yield* attempt( { operation: "pictureInPicture.readTitle", tabId, webContentsId: wc.id, }, () => wc.getTitle().trim(), ); const pictureInPictureWindow = yield* attempt( { operation: "pictureInPicture.create", tabId, webContentsId: wc.id, }, () => new BrowserWindow({ width: PICTURE_IN_PICTURE_INITIAL_WIDTH, height: PICTURE_IN_PICTURE_INITIAL_HEIGHT, minWidth: PICTURE_IN_PICTURE_MIN_WIDTH, minHeight: PICTURE_IN_PICTURE_MIN_HEIGHT, title: title.length > 0 ? `Preview · ${title}` : "Browser preview", show: false, alwaysOnTop: true, autoHideMenuBar: true, fullscreenable: false, maximizable: false, minimizable: false, resizable: true, skipTaskbar: true, backgroundColor: "#111111", ...(hostPlatform === "darwin" ? { type: "panel" as const } : {}), webPreferences: { preload: pictureInPicturePreloadPath, backgroundThrottling: false, contextIsolation: true, nodeIntegration: false, sandbox: true, }, }), ); yield* rendererHistory.register(pictureInPictureWindow.webContents, { surface: "picture-in-picture", tabId, }); const initializationScope = yield* Scope.fork(parentScope, "sequential"); const ready = yield* Deferred.make(); const session: PictureInPictureSession = { window: pictureInPictureWindow, webContentsId: wc.id, ready, initializationScope, }; const onClosed = () => { runFork( pictureInPictureMutationSemaphore.withPermit( releasePictureInPicture(tabId, session, false), ), ); }; const onDidFinishLoad = () => { runFork( SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => { const current = sessions.get(tabId); if (!current?.consumers.has("picture-in-picture")) return sessions; return replaceMap(sessions, (copy) => { copy.set(tabId, { ...current, lastPictureInPictureFingerprint: null }); }); }), ); }; const pipWebContents = pictureInPictureWindow.webContents; yield* attempt( { operation: "pictureInPicture.configure", tabId, webContentsId: wc.id, }, () => { pictureInPictureWindow.once("closed", onClosed); pictureInPictureWindow.setAlwaysOnTop( true, hostPlatform === "darwin" ? "floating" : "normal", ); if (hostPlatform === "darwin") { pictureInPictureWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true, // Electron's temporary UIElement transformation would remove the app from the Dock. skipTransformProcessType: true, }); } pipWebContents.on("did-finish-load", onDidFinishLoad); }, ).pipe( Effect.onError(() => Effect.all( [ Scope.close(initializationScope, Exit.void).pipe(Effect.ignore), attempt({ operation: "pictureInPicture.close", tabId }, () => pictureInPictureWindow.close(), ).pipe(Effect.ignore), ], { discard: true }, ), ), ); yield* Scope.addFinalizer( initializationScope, Effect.sync(() => { pipWebContents.off("did-finish-load", onDidFinishLoad); }).pipe(Effect.ignore), ); yield* SynchronizedRef.update(pictureInPictureSessionsRef, (sessions) => replaceMap(sessions, (copy) => { copy.set(tabId, session); }), ); return { kind: "created" as const, session }; }), ); const pictureInPictureSession = claim.session; if (claim.kind === "existing") { yield* Deferred.await(pictureInPictureSession.ready); return yield* pictureInPictureMutationSemaphore.withPermit( Effect.gen(function* () { const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId); if (current !== pictureInPictureSession || pictureInPictureSession.window.isDestroyed()) { return yield* new PreviewOperationError({ operation: "pictureInPicture.showExisting", tabId, webContentsId: pictureInPictureSession.webContentsId, cause: new Error("Picture-in-picture session closed before it became visible."), }); } yield* attempt( { operation: "pictureInPicture.showExisting", tabId, webContentsId: pictureInPictureSession.webContentsId, }, () => pictureInPictureSession.window.showInactive(), ); }), ); } const initialize = Effect.gen(function* () { yield* attemptPromise( { operation: "pictureInPicture.load", tabId, webContentsId: pictureInPictureSession.webContentsId, }, () => pictureInPictureSession.window.loadURL(buildPreviewPictureInPictureDataUrl()), ); const currentWebContents = yield* requireWebContents(tabId); if ( currentWebContents.id !== pictureInPictureSession.webContentsId || currentWebContents.isDestroyed() ) { return yield* new PreviewOperationError({ operation: "pictureInPicture.validateWebContents", tabId, webContentsId: pictureInPictureSession.webContentsId, cause: new Error("Preview webview changed while picture-in-picture was opening."), }); } yield* startFrameCapture(tabId, "picture-in-picture"); yield* attempt( { operation: "pictureInPicture.show", tabId, webContentsId: pictureInPictureSession.webContentsId, }, () => pictureInPictureSession.window.showInactive(), ); }); const initializationExit = yield* Effect.gen(function* () { const initializationFiber = yield* Effect.forkIn( initialize, pictureInPictureSession.initializationScope, ); return yield* Fiber.await(initializationFiber); }).pipe( Effect.onInterrupt(() => pictureInPictureMutationSemaphore.withPermit( releasePictureInPicture(tabId, pictureInPictureSession, true), ), ), ); if (Exit.isSuccess(initializationExit)) { const published = yield* pictureInPictureMutationSemaphore.withPermit( Effect.gen(function* () { const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId); if (current !== pictureInPictureSession || pictureInPictureSession.window.isDestroyed()) { if (current === pictureInPictureSession) { yield* releasePictureInPicture(tabId, pictureInPictureSession, false); } return false; } yield* update(tabId, { pictureInPicture: true }); yield* Deferred.done(pictureInPictureSession.ready, initializationExit); return true; }), ); if (published) return; return yield* Deferred.await(pictureInPictureSession.ready); } yield* Deferred.done(pictureInPictureSession.ready, initializationExit); const current = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(tabId); if (current === pictureInPictureSession) { yield* pictureInPictureMutationSemaphore.withPermit( releasePictureInPicture(tabId, pictureInPictureSession, true), ); } return yield* Effect.failCause(initializationExit.cause); }); const clearPendingRecording = (tabId: string) => { if (pendingRecording?.tabId === tabId) pendingRecording = null; }; /** Display-media requests lack tab identity; only one tab may arm a recording to prevent stream redirection. */ const armPendingRecording = Effect.fn("PreviewManager.armPendingRecording")(function* ( tabId: string, wc: Electron.WebContents, requestingFrameTreeNodeId: number, ) { const now = yield* Clock.currentTimeMillis; const previous = pendingRecording; if ( previous !== null && previous.tabId !== tabId && !previous.webContents.isDestroyed() && now - previous.armedAtMillis < RECORDING_ARM_GRACE_MS ) { return yield* new PreviewRecordingArmConflictError({ tabId, webContentsId: wc.id, armedTabId: previous.tabId, }); } const armed: PendingRecording = { tabId, webContents: wc, requestingFrameTreeNodeId, armedAtMillis: now, }; pendingRecording = armed; // The synchronous callback cannot read a clock. Compare identity so expiry cannot clobber a re-arm. yield* Effect.forkIn( Effect.sleep(RECORDING_ARM_GRACE_MS).pipe( Effect.andThen( Effect.sync(() => { if (pendingRecording === armed) pendingRecording = null; }), ), ), parentScope, ); }); // Only serve the tab armed by startRecording; pages must not capture independently. const installDisplayMediaRequestHandler = (session: Session) => { if (displayMediaHandlerSessions.has(session)) return; displayMediaHandlerSessions.add(session); session.setDisplayMediaRequestHandler((request, callback) => { const armed = pendingRecording; if (!armed) { callback({}); return; } if (armed.webContents.isDestroyed()) { pendingRecording = null; callback({}); return; } if (request.frame?.frameTreeNodeId !== armed.requestingFrameTreeNodeId) { callback({}); return; } pendingRecording = null; callback({ video: armed.webContents.mainFrame }); }); }; const startRecording = Effect.fn("PreviewManager.startRecording")(function* ( tabId: string, options: RecordingInputOptions = DEFAULT_RECORDING_INPUT_OPTIONS, ) { if ((yield* Ref.get(closingTabIdsRef)).has(tabId)) { return yield* new PreviewTabNotFoundError({ tabId }); } return yield* withTabLifecycleLock( tabId, Effect.gen(function* () { yield* startFrameCapture(tabId, "recording"); yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => replaceMap(sessions, (copy) => { const current = copy.get(tabId); if (current) copy.set(tabId, { ...current, recordingInputOptions: options }); }), ); const wc = yield* requireWebContents(tabId); const requestWebContents = wc.hostWebContents; if (requestWebContents === null) { return yield* new PreviewMainWindowClosedError({ tabId }); } const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); yield* attempt({ operation: "recording.cursor", tabId, webContentsId: wc.id }, () => wc.send(RECORDING_CURSOR_CHANNEL, true, options, tab?.controller), ); const warmupContext = { operation: "recording.warmSource", tabId, webContentsId: wc.id }; yield* Effect.tryPromise({ // Electron cannot cancel capturePage, but a stalled warmup must release the tab lock. try: (_signal) => wc.capturePage(), catch: (cause) => new PreviewOperationError({ ...warmupContext, cause }), }).pipe( Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS), Effect.catchTags({ TimeoutError: (cause) => Effect.fail(new PreviewOperationError({ ...warmupContext, cause })), }), Effect.retry({ times: 1 }), Effect.ignore, ); const currentWebContents = yield* requireWebContents(tabId); if (currentWebContents !== wc || wc.isDestroyed()) { return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId: wc.id, }); } if (!frameCaptureWindowOpen || requestWebContents.isDestroyed()) { return yield* new PreviewMainWindowClosedError({ tabId }); } installDisplayMediaRequestHandler(requestWebContents.session); yield* armPendingRecording(tabId, wc, requestWebContents.mainFrame.frameTreeNodeId); const captureRequested = yield* attemptPromise( { operation: "recording.requestCapture", tabId, webContentsId: requestWebContents.id, }, () => requestWebContents.executeJavaScript(requestRecordingCaptureExpression(tabId), true), ); if (captureRequested !== true) { return yield* new PreviewRecordingCaptureUnavailableError({ tabId, webContentsId: requestWebContents.id, }); } }).pipe( Effect.onError(() => { clearPendingRecording(tabId); return stopFrameCapture(tabId, "recording").pipe(Effect.ignore); }), ), ); }); const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) { // Clearing runs under the tab lock so it cannot land before an in-flight start arms. yield* withTabLifecycleLock( tabId, Effect.suspend(() => { clearPendingRecording(tabId); return stopFrameCapture(tabId, "recording"); }), ); }); const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( tabId: string, mimeType: string, data: Uint8Array, ) { const [createdAt, millis] = yield* Effect.all([currentIso, currentMillis]); const id = `browser-recording-${millis.toString(36)}`; const extension = recordingFileExtension(mimeType); const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`); yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe( Effect.mapError( (cause) => new PreviewOperationError({ operation: "saveRecording.makeDirectory", tabId, artifactPath, cause, }), ), ); yield* fileSystem.writeFile(artifactPath, data).pipe( Effect.mapError( (cause) => new PreviewOperationError({ operation: "saveRecording.writeFile", tabId, artifactPath, cause, }), ), ); return { id, tabId, path: artifactPath, mimeType, sizeBytes: data.byteLength, createdAt, }; }); const revealArtifact = Effect.fn("PreviewManager.revealArtifact")(function* ( artifactPath: string, ) { const resolvedPath = yield* resolveArtifactPath(artifactPath); yield* attempt({ operation: "revealArtifact", artifactPath: resolvedPath }, () => shell.showItemInFolder(resolvedPath), ); }); const copyArtifactToClipboard = Effect.fn("PreviewManager.copyArtifactToClipboard")(function* ( artifactPath: string, ) { const resolvedPath = yield* resolveArtifactPath(artifactPath); const image = yield* attempt( { operation: "copyArtifactToClipboard.load", artifactPath: resolvedPath }, () => nativeImage.createFromPath(resolvedPath), ); if (image.isEmpty()) { return yield* new PreviewArtifactImageLoadError({ artifactPath: resolvedPath }); } yield* attemptPromise( { operation: "copyArtifactToClipboard.write", artifactPath: resolvedPath }, () => clipboard.write([ new ClipboardItem({ "image/png": new Blob([Uint8Array.from(image.toPNG())], { type: "image/png" }), }), ]), ); }); let pointerSequence = 0; const emitAgentPointer = Effect.fn("PreviewManager.emitAgentPointer")(function* (pointer: { readonly key: { readonly threadId: string; readonly tabId: string }; readonly phase: "move" | "click"; readonly x: number; readonly y: number; }) { const tab = [...(yield* SynchronizedRef.get(tabsRef)).values()].find( (candidate) => candidate.serverTab?.threadId === pointer.key.threadId && candidate.serverTab.tabId === pointer.key.tabId, ); if (!tab) return; const event: DesktopPreviewPointerEvent = { tabId: tab.tabId, phase: pointer.phase, x: pointer.x, y: pointer.y, sequence: ++pointerSequence, createdAt: yield* currentIso, }; const recording = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tab.tabId); const webContentsId = tab.webContentsId; if (recording?.consumers.has("recording") && webContentsId != null) { yield* attempt({ operation: "recording.pointer", tabId: tab.tabId }, () => { const contents = webContents.fromId(webContentsId); if (contents && !contents.isDestroyed()) contents.send(RECORDING_POINTER_CHANNEL, event); }).pipe(Effect.ignore); } const listeners = yield* Ref.get(pointerEventListenersRef); yield* Effect.forEach( listeners, (listener) => deliverEvent("pointer-event", tab.tabId, () => listener(event)), { discard: true }, ); }); const updateServerTabCapturing = Effect.fn("PreviewManager.updateServerTabCapturing")( function* (painting: { readonly key: { readonly threadId: string; readonly tabId: string }; readonly active: boolean; }) { const tab = [...(yield* SynchronizedRef.get(tabsRef)).values()].find( (candidate) => candidate.serverTab?.threadId === painting.key.threadId && candidate.serverTab.tabId === painting.key.tabId, ); if (tab && tab.capturing !== painting.active) { yield* update(tab.tabId, { capturing: painting.active }); } }, ); let browserHostStarted = false; // Subscribes before the attach that follows, so no event falls between the two. const listenToBrowserHost = Effect.suspend(() => { if (browserHostStarted) return Effect.void; browserHostStarted = true; const listen = Effect.forkIn(parentScope, { startImmediately: true }); return Effect.all( [ browserHost.pointers.pipe(Stream.runForEach(emitAgentPointer), listen), browserHost.painting.pipe(Stream.runForEach(updateServerTabCapturing), listen), ], { discard: true }, ); }); const emitOpenLink = Effect.fn("PreviewManager.emitOpenLink")(function* ( event: DesktopPreviewOpenLinkEvent, ) { const listeners = yield* Ref.get(openLinkListenersRef); yield* Effect.forEach( listeners, (listener) => deliverEvent("open-link", event.tabId, () => listener(event)), { discard: true }, ); }); const emitFindResult = Effect.fn("PreviewManager.emitFindResult")(function* ( event: DesktopPreviewFindResultEvent, ) { const listeners = yield* Ref.get(findResultListenersRef); yield* Effect.forEach( listeners, (listener) => deliverEvent("find-result", event.tabId, () => listener(event)), { discard: true }, ); }); const subscribe = ( ref: Ref.Ref>, listener: A, ): Effect.Effect => Effect.acquireRelease( Ref.update(ref, (listeners) => new Set([...listeners, listener])), () => Ref.update(ref, (listeners) => { const next = new Set(listeners); next.delete(listener); return next; }), ).pipe(Effect.asVoid); const destroy = Effect.fn("PreviewManager.destroy")(function* () { const tabs = yield* SynchronizedRef.get(tabsRef); yield* Effect.forEach(tabs.keys(), closeTab, { discard: true }); yield* Effect.all( [ Ref.set(listenersRef, new Set()), Ref.set(pointerEventListenersRef, new Set()), Ref.set(openLinkListenersRef, new Set()), Ref.set(findResultListenersRef, new Set()), Ref.set(recordingInputListenersRef, new Set()), ], { discard: true }, ); }); yield* Effect.addFinalizer(() => destroy().pipe(Effect.ignore)); return { cancelPickElement, captureScreenshot, closeTab, copyArtifactToClipboard, createTab, findInPage, goBack, goForward, hardReload, navigate, openPictureInPicture, openDevTools, pickElement, updateAnnotationVoice, prepareWebview, reapplyZoom, refresh, registerWebview, resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR), setZoomFactor: (tabId: string, zoomFactor: number) => applyZoom(tabId, () => normalizeZoomFactor(zoomFactor)), revealArtifact, saveRecording, setAnnotationTheme, setAnnotationSendEnabled, setAudioMuted, setShown, setColorScheme, setMainWindow, setForwardedShortcuts: (shortcuts: ReadonlyArray) => Effect.sync(() => { forwardedShortcuts = shortcuts; }), startRecording, closePictureInPicture, stopFindInPage, stopRecording, subscribePointerEvents: (listener: PointerEventListener) => subscribe(pointerEventListenersRef, listener), subscribeOpenLinks: (listener: OpenLinkListener) => subscribe(openLinkListenersRef, listener), subscribeFindResults: (listener: FindResultListener) => subscribe(findResultListenersRef, listener), subscribeRecordingInputs: (listener: RecordingInputListener) => subscribe(recordingInputListenersRef, listener), subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener), zoomIn: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "in")), zoomOut: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "out")), }; }); export class PreviewTabNotFoundError extends Schema.TaggedError()( "PreviewTabNotFoundError", { tabId: Schema.String }, ) { override get message(): string { return `Preview tab not found: ${this.tabId}`; } } export class PreviewWebContentsNotFoundError extends Schema.TaggedError()( "PreviewWebContentsNotFoundError", { tabId: Schema.String, webContentsId: Schema.Number }, ) { override get message(): string { return `WebContents ${this.webContentsId} not found for preview tab ${this.tabId}`; } } export class PreviewWebviewNotInitializedError extends Schema.TaggedError()( "PreviewWebviewNotInitializedError", { tabId: Schema.String }, ) { override get message(): string { return `Preview tab "${this.tabId}" has no webview registered`; } } export class PreviewMainWindowClosedError extends Schema.TaggedError()( "PreviewMainWindowClosedError", { tabId: Schema.String }, ) { override get message(): string { return `Cannot start preview frame capture while the main window is closed: ${this.tabId}`; } } export class PreviewRecordingArmConflictError extends Schema.TaggedError()( "PreviewRecordingArmConflictError", { tabId: Schema.String, webContentsId: Schema.Number, armedTabId: Schema.String, }, ) { override get message(): string { return `Preview tab ${this.armedTabId} is still claiming the capture stream, so recording could not start for tab ${this.tabId}`; } } export class PreviewRecordingCaptureUnavailableError extends Schema.TaggedError()( "PreviewRecordingCaptureUnavailableError", { tabId: Schema.String, webContentsId: Schema.Number, }, ) { override get message(): string { return `Preview recording capture is unavailable for tab ${this.tabId} in WebContents ${this.webContentsId}`; } } export class PreviewOperationError extends Schema.TaggedError()( "PreviewOperationError", { operation: Schema.String, tabId: Schema.optional(Schema.String), webContentsId: Schema.optional(Schema.Number), artifactPath: Schema.optional(Schema.String), cause: Schema.Defect(), }, ) { static toTimelineMessage(error: PreviewOperationError): string { return error.cause instanceof Error ? error.cause.message : String(error.cause); } override get message(): string { const context = [ this.tabId === undefined ? undefined : `tab ${this.tabId}`, this.webContentsId === undefined ? undefined : `WebContents ${this.webContentsId}`, this.artifactPath === undefined ? undefined : `artifact ${this.artifactPath}`, ].filter((value): value is string => value !== undefined); return `Desktop preview operation failed: ${this.operation}${context.length === 0 ? "" : ` (${context.join(", ")})`}`; } } const isPreviewOperationError = Schema.is(PreviewOperationError); export class PreviewArtifactPathOutsideDirectoryError extends Schema.TaggedError()( "PreviewArtifactPathOutsideDirectoryError", { artifactPath: Schema.String, artifactDirectory: Schema.String, }, ) { override get message(): string { return `Preview artifact path ${this.artifactPath} is outside ${this.artifactDirectory}`; } } export class PreviewArtifactImageLoadError extends Schema.TaggedError()( "PreviewArtifactImageLoadError", { artifactPath: Schema.String }, ) { override get message(): string { return `Preview artifact could not be loaded as an image: ${this.artifactPath}`; } } export class PreviewAutomationDevToolsOpenError extends Schema.TaggedError()( "PreviewAutomationDevToolsOpenError", { webContentsId: Schema.Number }, ) { override get message(): string { return `Close preview DevTools before using agent browser control for WebContents ${this.webContentsId}`; } } export class PreviewAutomationDebuggerAttachedError extends Schema.TaggedError()( "PreviewAutomationDebuggerAttachedError", { webContentsId: Schema.Number }, ) { override get message(): string { return `Preview control cannot attach to WebContents ${this.webContentsId} because another debugger owns it`; } } export const PreviewManagerError = Schema.Union([ PreviewTabNotFoundError, PreviewWebContentsNotFoundError, PreviewWebviewNotInitializedError, PreviewMainWindowClosedError, PreviewRecordingArmConflictError, PreviewRecordingCaptureUnavailableError, PreviewOperationError, PreviewArtifactPathOutsideDirectoryError, PreviewArtifactImageLoadError, PreviewAutomationDevToolsOpenError, PreviewAutomationDebuggerAttachedError, ]); export type PreviewManagerError = typeof PreviewManagerError.Type; export class PreviewManager extends Context.Service< PreviewManager, { readonly setMainWindow: (window: BrowserWindow) => Effect.Effect; readonly setForwardedShortcuts: ( shortcuts: ReadonlyArray, ) => Effect.Effect; readonly getBrowserSession: ( scope?: string, persistent?: boolean, namespace?: BrowserSession.BrowserSessionPartitionNamespace, ) => Effect.Effect; readonly isBrowserPartition: (partition: string) => boolean; readonly createTab: ( tabId: string, defaults?: DesktopPreviewTabDefaults, ) => Effect.Effect; readonly closeTab: (tabId: string) => Effect.Effect; readonly registerWebview: ( tabId: string, webContentsId: number, ) => Effect.Effect; readonly prepareWebview: (webContents: Electron.WebContents) => Effect.Effect; readonly navigate: (tabId: string, url: string) => Effect.Effect; readonly goBack: (tabId: string) => Effect.Effect; readonly goForward: (tabId: string) => Effect.Effect; readonly refresh: (tabId: string) => Effect.Effect; readonly zoomIn: (tabId: string) => Effect.Effect; readonly zoomOut: (tabId: string) => Effect.Effect; readonly resetZoom: (tabId: string) => Effect.Effect; readonly reapplyZoom: () => Effect.Effect; readonly hardReload: (tabId: string) => Effect.Effect; readonly findInPage: ( tabId: string, text: string, options: { readonly forward: boolean; readonly newSession: boolean }, ) => Effect.Effect; readonly stopFindInPage: (tabId: string) => Effect.Effect; readonly setColorScheme: ( tabId: string, colorScheme: DesktopPreviewColorScheme, ) => Effect.Effect; readonly setZoomFactor: ( tabId: string, zoomFactor: number, ) => Effect.Effect; readonly setAudioMuted: ( tabId: string, audioMuted: boolean, ) => Effect.Effect; readonly setShown: (tabId: string, shown: boolean) => Effect.Effect; readonly openDevTools: (tabId: string) => Effect.Effect; readonly clearCookies: ( partitions?: ReadonlyArray, ) => Effect.Effect; readonly clearCache: ( partitions?: ReadonlyArray, ) => Effect.Effect; readonly getBrowserPartition: ( scope?: string, persistent?: boolean, namespace?: BrowserSession.BrowserSessionPartitionNamespace, ) => Effect.Effect; readonly setAnnotationTheme: ( theme: DesktopPreviewAnnotationTheme, ) => Effect.Effect; readonly setAnnotationSendEnabled: ( tabId: string, enabled: boolean, ) => Effect.Effect; readonly pickElement: ( tabId: string, voice?: DesktopPreviewAnnotationVoiceConfig, ) => Effect.Effect; readonly updateAnnotationVoice: ( state: DesktopPreviewAnnotationVoiceState, ) => Effect.Effect; readonly cancelPickElement: (tabId: string) => Effect.Effect; readonly captureScreenshot: ( tabId: string, ) => Effect.Effect; readonly revealArtifact: (path: string) => Effect.Effect; readonly copyArtifactToClipboard: (path: string) => Effect.Effect; readonly openPictureInPicture: (tabId: string) => Effect.Effect; readonly closePictureInPicture: (tabId: string) => Effect.Effect; readonly startRecording: ( tabId: string, options?: RecordingInputOptions, ) => Effect.Effect; readonly stopRecording: (tabId: string) => Effect.Effect; readonly saveRecording: ( tabId: string, mimeType: string, data: Uint8Array, ) => Effect.Effect; readonly subscribeStateChanges: (listener: Listener) => Effect.Effect; readonly subscribePointerEvents: ( listener: PointerEventListener, ) => Effect.Effect; readonly subscribeOpenLinks: ( listener: OpenLinkListener, ) => Effect.Effect; readonly subscribeFindResults: ( listener: FindResultListener, ) => Effect.Effect; readonly subscribeRecordingInputs: ( listener: RecordingInputListener, ) => Effect.Effect; } >()("@t3tools/desktop/preview/Manager/PreviewManager") {} /** @public */ export const make = Effect.gen(function* PreviewManagerMake() { const environment = yield* DesktopEnvironment.DesktopEnvironment; const browserSession = yield* BrowserSession.BrowserSession; const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost; const passkeys = yield* PreviewPasskeys.PreviewPasskeys; const downloadSessions = new WeakSet(); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runForkDownload = Effect.runForkWith(yield* Effect.context()); const copyToDownloads = Effect.fn("PreviewManager.copyToDownloads")(function* ( source: string, fileName: string, ) { const directory = app.getPath("downloads"); const extension = path.extname(fileName); const stem = path.basename(fileName, extension) || "download"; for (let attempt = 0; attempt < 100; attempt += 1) { const target = path.join( directory, attempt === 0 ? `${stem}${extension}` : `${stem} (${attempt})${extension}`, ); if (yield* fileSystem.exists(target)) continue; const copied = yield* Effect.tryPromise({ try: () => NodeFSP.copyFile(source, target, NodeFSP.constants.COPYFILE_EXCL), catch: (cause) => new PreviewOperationError({ operation: "copyToDownloads", cause }), }).pipe( Effect.as(true), Effect.catchIf( (error) => error.cause instanceof Error && "code" in error.cause && error.cause.code === "EEXIST", () => Effect.succeed(false), ), ); if (!copied) continue; shell.showItemInFolder(target); return; } }); const placeServerDownloads = (session: Electron.Session) => { if (downloadSessions.has(session)) return; downloadSessions.add(session); session.on("will-download", (_event, item, source) => { // Decided before the server's path is set: placing it consumes the guid. const human = browserHost.humanStartedDownload(source); if (!browserHost.placeDownload(source, item) || !human) return; item.once("done", (_doneEvent, state) => { if (state !== "completed") return; runForkDownload( copyToDownloads(item.getSavePath(), item.getFilename()).pipe( Effect.catch((error) => Effect.logWarning("preview download copy failed", { cause: error }), ), ), ); }); }); }; const operations = yield* makeNativeOperations( environment.browserArtifactsDir, environment.path.join(environment.dirname, "preview-pip-preload.cjs"), ); return PreviewManager.of({ setMainWindow: operations.setMainWindow, setForwardedShortcuts: operations.setForwardedShortcuts, getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")( function* (scope, persistent, namespace) { const session = yield* browserSession .getSession(scope, persistent, namespace) .pipe( Effect.mapError( (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }), ), ); placeServerDownloads(session); passkeys.installSessionHandlers(session); return session; }, ), isBrowserPartition: browserSession.isPartition, createTab: operations.createTab, closeTab: operations.closeTab, registerWebview: operations.registerWebview, prepareWebview: operations.prepareWebview, navigate: operations.navigate, goBack: operations.goBack, goForward: operations.goForward, refresh: operations.refresh, zoomIn: operations.zoomIn, zoomOut: operations.zoomOut, resetZoom: operations.resetZoom, reapplyZoom: operations.reapplyZoom, hardReload: operations.hardReload, findInPage: operations.findInPage, stopFindInPage: operations.stopFindInPage, setColorScheme: operations.setColorScheme, setZoomFactor: operations.setZoomFactor, setAudioMuted: operations.setAudioMuted, setShown: operations.setShown, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* (partitions) { yield* browserSession .clearCookies(partitions) .pipe( Effect.mapError( (cause) => new PreviewOperationError({ operation: "clearCookies", cause }), ), ); }), clearCache: Effect.fn("PreviewManager.clearCache")(function* (partitions) { yield* browserSession .clearCache(partitions) .pipe( Effect.mapError((cause) => new PreviewOperationError({ operation: "clearCache", cause })), ); }), getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")( function* (scope, persistent, namespace) { return yield* browserSession .getPartition(scope, persistent, namespace) .pipe( Effect.mapError( (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }), ), ); }, ), setAnnotationTheme: operations.setAnnotationTheme, setAnnotationSendEnabled: operations.setAnnotationSendEnabled, pickElement: operations.pickElement, updateAnnotationVoice: operations.updateAnnotationVoice, cancelPickElement: operations.cancelPickElement, captureScreenshot: operations.captureScreenshot, revealArtifact: operations.revealArtifact, copyArtifactToClipboard: operations.copyArtifactToClipboard, openPictureInPicture: operations.openPictureInPicture, closePictureInPicture: operations.closePictureInPicture, startRecording: operations.startRecording, stopRecording: operations.stopRecording, saveRecording: operations.saveRecording, subscribeStateChanges: operations.subscribeStateChanges, subscribePointerEvents: operations.subscribePointerEvents, subscribeOpenLinks: operations.subscribeOpenLinks, subscribeFindResults: operations.subscribeFindResults, subscribeRecordingInputs: operations.subscribeRecordingInputs, }); }).pipe(Effect.withSpan("PreviewManager.make")); export const layer = Layer.effect(PreviewManager, make);