3043 lines · 108.3 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,  DesktopPreviewPointerEvent,  PreviewAnnotationPayload,  PreviewAnnotationRect,  DesktopPreviewRecordingArtifact,  DesktopPreviewRecordingFrame,  DesktopPreviewScreenshotArtifact,  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 {  type 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 Effect from "effect/Effect";import * as Exit from "effect/Exit";import * as FileSystem from "effect/FileSystem";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 * as BrowserSession from "./BrowserSession.ts";import {  ANNOTATION_CAPTURED_CHANNEL,  ANNOTATION_THEME_CHANNEL,  CANCEL_PICK_CHANNEL,  ELEMENT_PICKED_CHANNEL,  HUMAN_INPUT_CHANNEL,  START_PICK_CHANNEL,} from "./GuestProtocol.ts";import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts";import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.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;  colorScheme: DesktopPreviewColorScheme;  controller: "human" | "agent" | "none";  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 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.UnknownFromJsonString);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 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;}; 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 scope: 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 },]); 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,) {  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(),  );  const pickSessionsRef = yield* Ref.make<ReadonlyMap<string, PickSession>>(new Map());  const controlSessionsRef = yield* SynchronizedRef.make<    ReadonlyMap<number, BrowserControlSession>  >(new Map());  const diagnosticsRef = yield* Ref.make<ReadonlyMap<number, BrowserDiagnostics>>(new Map());  const expectedAgentInputsRef = yield* Ref.make<    ReadonlyMap<string, ReadonlyArray<ExpectedAgentInput>>  >(new Map());  const controlEpochRef = yield* Ref.make<ReadonlyMap<string, number>>(new Map());  const actionTimelineRef = yield* Ref.make<    ReadonlyMap<string, ReadonlyArray<PreviewAutomationActionEvent>>  >(new Map());  const actionSequenceRef = yield* Ref.make(0);  const pointerSequenceRef = yield* Ref.make(0);  const recordingTabIdRef = yield* Ref.make<Option.Option<string>>(Option.none());   const attempt = <A>(errorContext: PreviewOperationContext, evaluate: () => A) =>    Effect.try({      try: evaluate,      catch: (cause) => new PreviewOperationError({ ...errorContext, cause }),    });  const attemptPromise = <A>(    errorContext: PreviewOperationContext,    evaluate: () => PromiseLike<A>,  ) =>    Effect.tryPromise({      try: evaluate,      catch: (cause) => new PreviewOperationError({ ...errorContext, cause }),    });  const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso));  const currentMillis = Clock.currentTimeMillis;  const encodeJson = (errorContext: PreviewOperationContext, value: unknown) =>    encodeUnknownJson(value).pipe(      Effect.mapError((cause) => new PreviewOperationError({ ...errorContext, cause })),    );  const nextCounter = (ref: Ref.Ref<number>) =>    Ref.modify(ref, (value) => [value, value + 1] as const);  const replaceMap = <K, V>(    source: ReadonlyMap<K, V>,    update: (copy: Map<K, V>) => void,  ): ReadonlyMap<K, V> => {    const copy = new Map(source);    update(copy);    return copy;  };   const deliverEvent = (    eventKind: "state-change" | "recording-frame" | "pointer-event",    tabId: string,    delivery: () => Effect.Effect<void>,  ) =>    Effect.suspend(delivery).pipe(      Effect.catchCause((cause) =>        Cause.hasInterrupts(cause)          ? Effect.failCause(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 update = Effect.fn("PreviewManager.update")(function* (    tabId: string,    patch: Partial<PreviewTabState>,  ) {    const updatedAt = yield* currentIso;    const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {      const current = tabs.get(tabId);      if (!current) return [Option.none<PreviewTabState>(), tabs] as const;      const state: PreviewTabState = { ...current, ...patch, updatedAt };      return [        Option.some(state),        replaceMap(tabs, (copy) => {          copy.set(tabId, state);        }),      ] as const;    });    if (Option.isSome(next)) yield* emit(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) {

Lines 1–500 of 3043.