3998 lines · 143.8 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 type {  DesktopPreviewAnnotationTheme,  DesktopPreviewColorScheme,  DesktopPreviewFavicon,  DesktopPreviewPointerEvent,  PreviewAnnotationPayload,  PreviewAnnotationRect,  PreviewAnnotationSubmissionResult,  DesktopPreviewRecordingArtifact,  DesktopPreviewRecordingFrame,  DesktopPreviewScreenshotArtifact,  DesktopPreviewTabDefaults,  PreviewAutomationClickInput,  PreviewAutomationActionEvent,  PreviewAutomationConsoleEntry,  PreviewAutomationEvaluateInput,  PreviewAutomationPressInput,  PreviewAutomationNetworkEntry,  PreviewAutomationScrollInput,  PreviewAutomationSnapshot,  PreviewAutomationStatus,  PreviewAutomationTypeInput,  PreviewAutomationWaitForInput,} from "@t3tools/contracts";import { HostProcessPlatform } from "@t3tools/shared/hostProcess";import { normalizePreviewUrl } from "@t3tools/shared/preview";import { BrowserWindow, type Session, clipboard, nativeImage, shell, webContents } from "electron";import * as Cause from "effect/Cause";import * as Clock from "effect/Clock";import * as Context from "effect/Context";import * as DateTime from "effect/DateTime";import * as Deferred from "effect/Deferred";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";import * as FileSystem from "effect/FileSystem";import * as Fiber from "effect/Fiber";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as Ref from "effect/Ref";import * as Schema from "effect/Schema";import * as Semaphore from "effect/Semaphore";import * as Scope from "effect/Scope";import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";import * as BrowserSession from "./BrowserSession.ts";import {  ANNOTATION_CAPTURED_CHANNEL,  ANNOTATION_THEME_CHANNEL,  CANCEL_PICK_CHANNEL,  ELEMENT_PICKED_CHANNEL,  HUMAN_INPUT_CHANNEL,  MOUSE_NAVIGATE_CHANNEL,  START_PICK_CHANNEL,} from "./GuestProtocol.ts";import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts";import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts";import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; export type PreviewNavStatus =  | { kind: "Idle" }  | { kind: "Loading"; url: string; title: string }  | { kind: "Success"; url: string; title: string }  | {      kind: "LoadFailed";      url: string;      title: string;      code: number;      description: string;    }; export interface PreviewTabState {  tabId: string;  webContentsId: number | null;  navStatus: PreviewNavStatus;  canGoBack: boolean;  canGoForward: boolean;  zoomFactor: number;  pictureInPicture: boolean;  colorScheme: DesktopPreviewColorScheme;  controller: "human" | "agent" | "none";  favicon?: DesktopPreviewFavicon;  updatedAt: string;} /** Discrete zoom levels mirroring Chrome's preset list. */const ZOOM_LEVELS: ReadonlyArray<number> = [  0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0,]; const DEFAULT_ZOOM_FACTOR = 1.0;const ZOOM_EPSILON = 0.001;const MAX_EVALUATION_BYTES = 64_000;const MAX_VISIBLE_TEXT_LENGTH = 20_000;const MAX_INTERACTIVE_ELEMENTS = 200;const MAX_SCREENSHOT_WIDTH = 1280;const RECORDING_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12);const RECORDING_JPEG_QUALITY = 80;const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480;const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320;const PICTURE_IN_PICTURE_MIN_WIDTH = 240;const PICTURE_IN_PICTURE_MIN_HEIGHT = 160;const PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON = 0.002;const DIAGNOSTIC_BUFFER_LIMIT = 200;const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80;const AGENT_CURSOR_MOVE_MS = 160;const AGENT_CURSOR_CLICK_LEAD_MS = 40;const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = {  colorScheme: "light",  radius: "0.625rem",  background: "white",  foreground: "oklch(0.269 0 0)",  popover: "white",  popoverForeground: "oklch(0.269 0 0)",  primary: "oklch(0.488 0.217 264)",  primaryForeground: "white",  muted: "rgb(0 0 0 / 4%)",  mutedForeground: "oklch(0.556 0 0)",  accent: "rgb(0 0 0 / 4%)",  accentForeground: "oklch(0.269 0 0)",  border: "rgb(0 0 0 / 8%)",  input: "rgb(0 0 0 / 10%)",  ring: "oklch(0.488 0.217 264)",  fontSans: "system-ui, sans-serif",  fontMono: "ui-monospace, monospace",}; export const buildPreviewPictureInPictureDataUrl = (): string => {  const html = `<!doctype html><html>  <head>    <meta charset="utf-8">    <meta      http-equiv="Content-Security-Policy"      content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'"    >    <meta name="color-scheme" content="dark">    <style>      html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #111; }      body { display: grid; place-items: center; }      img { width: 100%; height: 100%; object-fit: contain; user-select: none; -webkit-user-drag: none; }    </style>  </head>  <body>    <img id="preview-frame" alt="Live browser preview">    <script>      const frame = document.getElementById("preview-frame");      window.previewPictureInPicture.onFrame((next) => {        frame.src = "data:image/jpeg;base64," + next.data;      });    </script>  </body></html>`;  return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;}; export const fitPictureInPictureContentSize = (  current: ReadonlyArray<number>,  aspectRatio: number,): readonly [width: number, height: number] => {  const currentWidth = Math.max(1, current[0] ?? PICTURE_IN_PICTURE_INITIAL_WIDTH);  const currentHeight = Math.max(1, current[1] ?? PICTURE_IN_PICTURE_INITIAL_HEIGHT);  const currentArea = currentWidth * currentHeight;  let width = Math.sqrt(currentArea * aspectRatio);  let height = width / aspectRatio;  const minimumScale = Math.max(    1,    PICTURE_IN_PICTURE_MIN_WIDTH / width,    PICTURE_IN_PICTURE_MIN_HEIGHT / height,  );  width *= minimumScale;  height *= minimumScale;  return [Math.round(width), Math.round(height)];}; const artifactSiteSlug = (rawUrl: string): string => {  try {    const url = new URL(rawUrl);    const slug = url.hostname      .toLowerCase()      .replace(/[^a-z0-9]+/g, "-")      .replace(/^-+|-+$/g, "")      .slice(0, MAX_ARTIFACT_SITE_SLUG_LENGTH)      .replace(/-+$/g, "");    return slug || "site";  } catch {    return "site";  }}; interface CdpEvaluationResult {  readonly result?: {    readonly value?: unknown;    readonly description?: string;  };  readonly exceptionDetails?: {    readonly text?: string;    readonly exception?: { readonly description?: string };  };} export const PreviewAutomationSelectorKind = Schema.Literals([  "focused-element",  "selector",  "locator",]);export type PreviewAutomationSelectorKind = typeof PreviewAutomationSelectorKind.Type; export const PreviewAutomationEvaluationDetailKind = Schema.Literals([  "exception-description",  "exception-text",  "unknown",]);export type PreviewAutomationEvaluationDetailKind =  typeof PreviewAutomationEvaluationDetailKind.Type; const previewAutomationEvaluationDetail = (exceptionDetails: unknown) => {  if (typeof exceptionDetails !== "object" || exceptionDetails === null) {    return { detailKind: "unknown" as const };  }  const details = exceptionDetails as Record<string, unknown>;  const exception = details["exception"];  const description =    typeof exception === "object" &&    exception !== null &&    typeof (exception as Record<string, unknown>)["description"] === "string"      ? (exception as Record<string, unknown>)["description"]      : undefined;  if (typeof description === "string" && description.length > 0) {    return { detailKind: "exception-description" as const, detail: description };  }  const text = details["text"];  if (typeof text === "string" && text.length > 0) {    return { detailKind: "exception-text" as const, detail: text };  }  return { detailKind: "unknown" as const };}; const previewAutomationTargetLabel = (  selectorKind: PreviewAutomationSelectorKind,  selectorLength?: number,) =>  selectorKind === "focused-element"    ? "the focused element"    : `${selectorKind} (${selectorLength ?? 0} characters)`; interface PreviewOperationContext {  readonly operation: string;  readonly tabId?: string;  readonly webContentsId?: number;  readonly artifactPath?: string;} const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {  if (typeof value !== "object" || value === null) return null;  const rect = value as Record<string, unknown>;  const x = rect["x"];  const y = rect["y"];  const width = rect["width"];  const height = rect["height"];  if (    typeof x !== "number" ||    !Number.isFinite(x) ||    typeof y !== "number" ||    !Number.isFinite(y) ||    typeof width !== "number" ||    !Number.isFinite(width) ||    typeof height !== "number" ||    !Number.isFinite(height) ||    width <= 0 ||    height <= 0  ) {    return null;  }  return {    x: Math.max(0, Math.floor(x)),    y: Math.max(0, Math.floor(y)),    width: Math.max(1, Math.ceil(width)),    height: Math.max(1, Math.ceil(height)),  };}; const captureAnnotationScreenshot = (  tabId: string,  wc: Electron.WebContents,  cropRect: PreviewAnnotationRect | null,): Effect.Effect<PreviewAnnotationPayload["screenshot"], PreviewManagerError> =>  Effect.tryPromise({    try: () =>      wc.capturePage(        cropRect          ? {              x: cropRect.x,              y: cropRect.y,              width: cropRect.width,              height: cropRect.height,            }          : undefined,      ),    catch: (cause) =>      new PreviewOperationError({        operation: "captureAnnotationScreenshot",        tabId,        webContentsId: wc.id,        cause,      }),  }).pipe(    Effect.map((image) => {      const size = image.getSize();      return {        dataUrl: image.toDataURL(),        width: size.width,        height: size.height,        cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height },      };    }),  ); const findZoomStep = (current: number): number => {  const index = ZOOM_LEVELS.findIndex(    (level) => Math.abs(level - current) < ZOOM_EPSILON || level > current,  );  if (index < 0) return ZOOM_LEVELS.length - 1;  return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1;}; /** * Clamp a client-supplied zoom factor onto the discrete ladder. The setting is * chosen from the same ladder, but it arrives over IPC from a schema that only * guarantees a positive number, so an out-of-band value snaps to the nearest * step rather than leaving the guest at a zoom the zoom controls can't reach. */const normalizeZoomFactor = (value: number | undefined): number => {  if (value === undefined || !Number.isFinite(value)) return DEFAULT_ZOOM_FACTOR;  let closest = ZOOM_LEVELS[0]!;  for (const level of ZOOM_LEVELS) {    if (Math.abs(level - value) < Math.abs(closest - value)) closest = level;  }  return closest;}; const nextZoomLevel = (current: number, direction: "in" | "out"): number => {  const step = findZoomStep(current);  if (direction === "in") {    return ZOOM_LEVELS[Math.min(step + 1, ZOOM_LEVELS.length - 1)] ?? current;  }  return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current;}; type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect<void>;type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect<void>; type PreviewInputSignal =  | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number }  | { readonly kind: "key"; readonly key: string; readonly code: string }; interface ManagedListeners {  readonly attachmentId: symbol;  readonly cancelFaviconCapture: () => void;  readonly scope: Scope.Closeable;  readonly webContents: Electron.WebContents;} type FrameCaptureConsumer = "picture-in-picture" | "recording"; interface FrameCaptureSession {  readonly scope: Scope.Closeable;  readonly consumers: ReadonlySet<FrameCaptureConsumer>;} interface PictureInPictureSession {  readonly window: BrowserWindow;  readonly webContentsId: number;  readonly ready: Deferred.Deferred<void, PreviewManagerError>;  readonly initializationScope: Scope.Closeable;} interface PickSession {  readonly cancel: Effect.Effect<void>;} interface BrowserControlSession {  readonly webContentsId: number;  readonly semaphore: Semaphore.Semaphore;  readonly scope: Scope.Closeable;  readonly onMessage: (    event: Electron.Event,    method: string,    params: Record<string, unknown>,  ) => void;} interface BrowserDiagnostics {  readonly consoleEntries: ReadonlyArray<PreviewAutomationConsoleEntry>;  readonly networkEntries: ReadonlyArray<PreviewAutomationNetworkEntry>;  readonly requests: ReadonlyMap<string, { url: string; method: string }>;} type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect<void>; interface ExpectedAgentInput {  readonly signal: PreviewInputSignal;  readonly expiresAt: number;} const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{  key: string;  meta: boolean;  shift: boolean;  control: boolean;}> = Object.freeze([  // mod+shift+J → preview.toggle  { key: "j", meta: true, shift: true, control: false },  // mod+K → command palette  { key: "k", meta: true, shift: false, control: false },  // mod+, → settings (macOS convention)  { key: ",", meta: true, shift: false, control: false },  // mod+W → close tab/panel  { key: "w", meta: true, shift: false, control: false },]); export const isPreviewRefreshShortcut = (input: Electron.Input): boolean =>  input.type === "keyDown" &&  input.key.toLowerCase() === "r" &&  (input.meta || input.control) &&  !input.shift &&  !input.alt; 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 inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): boolean => {  if (left.kind !== right.kind) return false;  if (left.kind === "pointer" && right.kind === "pointer") {    return (      Math.abs(left.x - right.x) <= 1 &&      Math.abs(left.y - right.y) <= 1 &&      left.button === right.button    );  }  return (    left.kind === "key" &&    right.kind === "key" &&    left.key === right.key &&    left.code === right.code  );}; const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function* (  artifactDirectory: string,  pictureInPicturePreloadPath: string,) {  const fileSystem = yield* FileSystem.FileSystem;  const hostPlatform = yield* HostProcessPlatform;  const path = yield* Path.Path;  const parentScope = yield* Scope.Scope;  const context = yield* Effect.context<never>();  const runFork = Effect.runForkWith(context);  const resolvedArtifactDirectory = path.resolve(artifactDirectory);  const playwrightInstallExpression = yield* Effect.cached(    playwrightInjectedRuntimeInstallExpression(),  );   const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);  const mainWindowRef = yield* Ref.make<Option.Option<BrowserWindow>>(Option.none());  const tabsRef = yield* SynchronizedRef.make<ReadonlyMap<string, PreviewTabState>>(new Map());  const attachedRef = yield* Ref.make<ReadonlyMap<number, ManagedListeners>>(new Map());  const listenersRef = yield* Ref.make<ReadonlySet<Listener>>(new Set());  const pointerEventListenersRef = yield* Ref.make<ReadonlySet<PointerEventListener>>(new Set());  const recordingFrameListenersRef = yield* Ref.make<ReadonlySet<RecordingFrameListener>>(    new Set(),

Lines 1–500 of 3998.