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

Lines 1–500 of 4738.