diff --git a/apps/desktop/scripts/build-preview-annotation-css.mjs b/apps/desktop/scripts/build-preview-annotation-css.mjs index e9dcff227d7ecc14172d168f436f0098ae61303f..c45f81268a6ceb888a7c1a8a2012c84c6d1c5aff 100644 --- a/apps/desktop/scripts/build-preview-annotation-css.mjs +++ b/apps/desktop/scripts/build-preview-annotation-css.mjs @@ -7,9 +7,9 @@ import { compile } from "tailwindcss"; const directory = dirname(fileURLToPath(import.meta.url)); const appRoot = join(directory, ".."); -const sourcePath = join(appRoot, "src", "preview-annotation.css"); -const preloadPath = join(appRoot, "src", "preview-pick-preload.ts"); -const outputPath = join(appRoot, "src", "preview-annotation-styles.generated.ts"); +const sourcePath = join(appRoot, "src", "preview", "Annotation.css"); +const preloadPath = join(appRoot, "src", "preview", "PickPreload.ts"); +const outputPath = join(appRoot, "src", "preview", "AnnotationStyles.generated.ts"); const require = createRequire(import.meta.url); const tailwindRoot = dirname(require.resolve("tailwindcss/package.json")); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 052a25e4b97c36ee54914a37d56f6a13f2bd3956..4da1ce63bdf8768dbe843b2717d4f1b0a64b0556 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -176,7 +176,7 @@ const bootstrap = Effect.gen(function* () { ); } - yield* installDesktopIpcHandlers; + yield* installDesktopIpcHandlers(); yield* logBootstrapInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 0cb8976ed3b007c140586b2aadd4739d257d1cd3..a6c8428efa9a664876893a9589be3323d2483dd0 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,7 +1,5 @@ import * as Effect from "effect/Effect"; -import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import { previewViewManager } from "../preview-view-manager.ts"; import * as DesktopIpc from "./DesktopIpc.ts"; import { clearCloudAuthToken, @@ -50,12 +48,11 @@ import { setTheme, showContextMenu, } from "./methods/window.ts"; -import { previewMethods } from "./methods/preview.ts"; +import * as PreviewIpc from "./methods/preview.ts"; -export const installDesktopIpcHandlers = Effect.gen(function* () { +export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - previewViewManager.configureArtifactDirectory(environment.browserArtifactsDir); + yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getLocalEnvironmentBootstrap); @@ -97,7 +94,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(downloadUpdate); yield* ipc.handle(installUpdate); yield* ipc.handle(checkForUpdate); - for (const previewMethod of previewMethods) { + for (const previewMethod of PreviewIpc.methods) { yield* ipc.handle(previewMethod); } -}).pipe(Effect.withSpan("desktop.ipc.installHandlers")); +}); diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index 8332d5b80ca557c467c92ac083aa6f5b57b2f81d..92336cc7362f5fc60489bca24937c989b573b17a 100644 --- a/apps/desktop/src/ipc/methods/preview.test.ts +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -1,8 +1,19 @@ +import { it as effectIt } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -const fromPartition = vi.fn(() => { - throw new Error("Session can only be received when app is ready"); -}); +import * as PreviewManager from "../../preview/Manager.ts"; +import * as PreviewIpc from "./preview.ts"; + +const { fromPartition } = vi.hoisted(() => ({ + fromPartition: vi.fn(() => { + throw new Error("Session can only be received when app is ready"); + }), +})); vi.mock("electron", () => ({ BrowserWindow: { @@ -25,4 +36,19 @@ describe("preview IPC methods", () => { await expect(import("./preview.ts")).resolves.toBeDefined(); expect(fromPartition).not.toHaveBeenCalled(); }); + + effectIt.effect("rejects invalid webContents ids before resolving the preview service", () => + Effect.map( + PreviewIpc.registerWebview + .handler({ tabId: "tab-1", webContentsId: 0 }) + .pipe(Effect.provideService(PreviewManager.PreviewManager, null as never), Effect.exit), + (exit) => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error) && Schema.isSchemaError(error.value)).toBe(true); + expect(fromPartition).not.toHaveBeenCalled(); + }, + ), + ); }); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index c9b8c58fcbddfda04697bca64b3613a49cb1d919..395cbad1ced5f3b967f399ed39b8da6a6eb8630d 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,251 +1,378 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import type { DesktopPreviewAnnotationTheme } from "@t3tools/contracts"; +import { + DesktopPreviewAnnotationThemeInputSchema, + DesktopPreviewArtifactInputSchema, + DesktopPreviewAutomationClickInputSchema, + DesktopPreviewAutomationEvaluateInputSchema, + DesktopPreviewAutomationPressInputSchema, + DesktopPreviewAutomationScrollInputSchema, + DesktopPreviewAutomationTypeInputSchema, + DesktopPreviewAutomationWaitForInputSchema, + DesktopPreviewConfigInputSchema, + DesktopPreviewNavigateInputSchema, + DesktopPreviewRecordingArtifactSchema, + DesktopPreviewRecordingSaveInputSchema, + DesktopPreviewRegisterWebviewInputSchema, + DesktopPreviewScreenshotArtifactSchema, + DesktopPreviewTabInputSchema, + DesktopPreviewWebviewConfigSchema, + PreviewAnnotationPayloadSchema, + PreviewAutomationSnapshot, + PreviewAutomationStatus, +} from "@t3tools/contracts"; import { BrowserWindow } from "electron"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { pathToFileURL } from "node:url"; -import { previewViewManager } from "../../preview-view-manager.ts"; -import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview-webview-preferences.ts"; +import * as PreviewManager from "../../preview/Manager.ts"; +import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; import * as IpcChannels from "../channels.ts"; -import type { DesktopIpcMethod } from "../DesktopIpc.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; -previewViewManager.onStateChange((tabId, state) => { +const broadcast = (channel: string, ...args: ReadonlyArray): void => { for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) { - window.webContents.send(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state); + window.webContents.send(channel, ...args); } } -}); +}; -previewViewManager.onRecordingFrame((frame) => { - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame); - } - } +export const installPreviewEventForwarding = Effect.fn( + "desktop.ipc.preview.installEventForwarding", +)(function* () { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.subscribeStateChanges((tabId, state) => { + broadcast(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state); + }); + yield* manager.subscribeRecordingFrames((frame) => { + broadcast(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame); + }); + yield* manager.subscribePointerEvents((event) => { + broadcast(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event); + }); }); -previewViewManager.onPointerEvent((event) => { - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event); - } - } +export const createTab = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.createTab")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.createTab(tabId); + }), }); -const tabIdFrom = (raw: unknown): string => { - if (typeof raw !== "object" || raw === null || !("tabId" in raw)) { - throw new Error("preview tab id is required"); - } - const tabId = raw.tabId; - if (typeof tabId !== "string" || tabId.trim().length === 0) { - throw new Error("preview tab id must be a non-empty string"); - } - return tabId; -}; - -const inputFrom = (raw: unknown): unknown => { - if (typeof raw !== "object" || raw === null || !("input" in raw)) { - throw new Error("preview automation input is required"); - } - return raw.input; -}; +export const closeTab = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.closeTab")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.closeTab(tabId); + }), +}); -const annotationThemeFrom = (raw: unknown): DesktopPreviewAnnotationTheme => { - if (typeof raw !== "object" || raw === null || !("theme" in raw)) { - throw new Error("preview annotation theme is required"); - } - const theme = raw.theme; - if (typeof theme !== "object" || theme === null) { - throw new Error("preview annotation theme must be an object"); - } - const record = theme as Record; - const stringKeys = [ - "radius", - "background", - "foreground", - "popover", - "popoverForeground", - "primary", - "primaryForeground", - "muted", - "mutedForeground", - "accent", - "accentForeground", - "border", - "input", - "ring", - "fontSans", - "fontMono", - ] as const; - for (const key of stringKeys) { - if (typeof record[key] !== "string" || record[key].length === 0) { - throw new Error(`preview annotation theme ${key} must be a non-empty string`); - } - } - if (record["colorScheme"] !== "light" && record["colorScheme"] !== "dark") { - throw new Error("preview annotation theme colorScheme must be light or dark"); - } - return record as unknown as DesktopPreviewAnnotationTheme; -}; +export const registerWebview = makeIpcMethod({ + channel: IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, + payload: DesktopPreviewRegisterWebviewInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.registerWebview")(function* ({ tabId, webContentsId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.registerWebview(tabId, webContentsId); + }), +}); -class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{ - readonly cause: unknown; -}> {} +export const navigate = makeIpcMethod({ + channel: IpcChannels.PREVIEW_NAVIGATE_CHANNEL, + payload: DesktopPreviewNavigateInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.navigate")(function* ({ tabId, url }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.navigate(tabId, url); + }), +}); -const method = ( +const tabMethod = ( channel: string, - handler: (raw: unknown) => unknown | Promise, -): DesktopIpcMethod => ({ - channel, - handler: (raw) => - Effect.tryPromise({ - try: () => Promise.resolve(handler(raw)), - catch: (cause) => new PreviewIpcError({ cause }), + name: string, + invoke: ( + manager: PreviewManager.PreviewManagerShape, + tabId: string, + ) => Effect.Effect, +) => + makeIpcMethod({ + channel, + payload: DesktopPreviewTabInputSchema, + result: Schema.Void, + handler: Effect.fn(name)(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* invoke(manager, tabId); }), -}); + }); + +export const goBack = tabMethod( + IpcChannels.PREVIEW_GO_BACK_CHANNEL, + "desktop.ipc.preview.goBack", + (manager, tabId) => manager.goBack(tabId), +); +export const goForward = tabMethod( + IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, + "desktop.ipc.preview.goForward", + (manager, tabId) => manager.goForward(tabId), +); +export const refresh = tabMethod( + IpcChannels.PREVIEW_REFRESH_CHANNEL, + "desktop.ipc.preview.refresh", + (manager, tabId) => manager.refresh(tabId), +); +export const zoomIn = tabMethod( + IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, + "desktop.ipc.preview.zoomIn", + (manager, tabId) => manager.zoomIn(tabId), +); +export const zoomOut = tabMethod( + IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, + "desktop.ipc.preview.zoomOut", + (manager, tabId) => manager.zoomOut(tabId), +); +export const resetZoom = tabMethod( + IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, + "desktop.ipc.preview.resetZoom", + (manager, tabId) => manager.resetZoom(tabId), +); +export const hardReload = tabMethod( + IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, + "desktop.ipc.preview.hardReload", + (manager, tabId) => manager.hardReload(tabId), +); +export const openDevTools = tabMethod( + IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, + "desktop.ipc.preview.openDevTools", + (manager, tabId) => manager.openDevTools(tabId), +); +export const cancelPickElement = tabMethod( + IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, + "desktop.ipc.preview.cancelPickElement", + (manager, tabId) => manager.cancelPickElement(tabId), +); +export const startRecording = tabMethod( + IpcChannels.PREVIEW_RECORDING_START_CHANNEL, + "desktop.ipc.preview.startRecording", + (manager, tabId) => manager.startRecording(tabId), +); +export const stopRecording = tabMethod( + IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, + "desktop.ipc.preview.stopRecording", + (manager, tabId) => manager.stopRecording(tabId), +); -export const previewMethods = [ - method(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, (raw) => - previewViewManager.createTab(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, (raw) => - previewViewManager.closeTab(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, (raw) => { - const tabId = tabIdFrom(raw); - const webContentsId = - typeof raw === "object" && raw !== null && "webContentsId" in raw ? raw.webContentsId : null; - if ( - typeof webContentsId !== "number" || - !Number.isInteger(webContentsId) || - webContentsId <= 0 - ) { - throw new Error("preview webContentsId must be a positive integer"); - } - return previewViewManager.registerWebview(tabId, webContentsId); +export const clearCookies = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* () { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.clearCookies(); }), - method(IpcChannels.PREVIEW_NAVIGATE_CHANNEL, (raw) => { - const tabId = tabIdFrom(raw); - const url = typeof raw === "object" && raw !== null && "url" in raw ? raw.url : null; - if (typeof url !== "string") throw new Error("preview url must be a string"); - return previewViewManager.navigate(tabId, url); +}); + +export const clearCache = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.clearCache(); }), - method(IpcChannels.PREVIEW_GO_BACK_CHANNEL, (raw) => previewViewManager.goBack(tabIdFrom(raw))), - method(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, (raw) => - previewViewManager.goForward(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_REFRESH_CHANNEL, (raw) => previewViewManager.refresh(tabIdFrom(raw))), - method(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, (raw) => previewViewManager.zoomIn(tabIdFrom(raw))), - method(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, (raw) => previewViewManager.zoomOut(tabIdFrom(raw))), - method(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, (raw) => - previewViewManager.resetZoom(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, (raw) => - previewViewManager.hardReload(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, (raw) => - previewViewManager.openDevTools(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, () => previewViewManager.clearCookies()), - method(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, () => previewViewManager.clearCache()), - method(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, (raw) => { - const environmentId = - typeof raw === "object" && raw !== null && "environmentId" in raw ? raw.environmentId : null; - if (typeof environmentId !== "string" || environmentId.length === 0) { - throw new Error("preview environment id is required"); - } - previewViewManager.getBrowserSession(environmentId); - const preloadPath = `${__dirname}/preview-pick-preload.cjs`; +}); + +export const getPreviewConfig = makeIpcMethod({ + channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, + payload: DesktopPreviewConfigInputSchema, + result: DesktopPreviewWebviewConfigSchema, + handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.getBrowserSession(environmentId); return { - partition: previewViewManager.getBrowserPartition(environmentId), + partition: manager.getBrowserPartition(environmentId), webPreferences: PREVIEW_WEBVIEW_PREFERENCES, - preloadUrl: pathToFileURL(preloadPath).href, + preloadUrl: pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href, }; }), - method(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, (raw) => - previewViewManager.setAnnotationTheme(annotationThemeFrom(raw)), - ), - method(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, (raw) => - previewViewManager.pickElement(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) => - previewViewManager.cancelPickElement(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, (raw) => - previewViewManager.captureScreenshot(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, (raw) => { - const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null; - if (typeof path !== "string" || path.trim().length === 0) { - throw new Error("preview artifact path is required"); - } - return previewViewManager.revealArtifact(path); +}); + +export const setAnnotationTheme = makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, + payload: DesktopPreviewAnnotationThemeInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setAnnotationTheme")(function* ({ theme }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setAnnotationTheme(theme); }), - method(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, (raw) => { - const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null; - if (typeof path !== "string" || path.trim().length === 0) { - throw new Error("preview artifact path is required"); - } - return previewViewManager.copyArtifactToClipboard(path); +}); + +export const pickElement = makeIpcMethod({ + channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: Schema.NullOr(PreviewAnnotationPayloadSchema), + handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.pickElement(tabId); }), - method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) => - previewViewManager.automationStatus(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, (raw) => - previewViewManager.automationSnapshot(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, (raw) => - previewViewManager.automationClick( - tabIdFrom(raw), - inputFrom(raw) as Parameters[1], - ), - ), - method(IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, (raw) => - previewViewManager.automationType( - tabIdFrom(raw), - inputFrom(raw) as Parameters[1], - ), - ), - method(IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, (raw) => - previewViewManager.automationPress( - tabIdFrom(raw), - inputFrom(raw) as Parameters[1], - ), - ), - method(IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, (raw) => - previewViewManager.automationScroll( - tabIdFrom(raw), - inputFrom(raw) as Parameters[1], - ), - ), - method(IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, (raw) => - previewViewManager.automationEvaluate( - tabIdFrom(raw), - inputFrom(raw) as Parameters[1], - ), - ), - method(IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, (raw) => - previewViewManager.automationWaitFor( - tabIdFrom(raw), - inputFrom(raw) as Parameters[1], - ), - ), - method(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, (raw) => - previewViewManager.startRecording(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, (raw) => - previewViewManager.stopRecording(tabIdFrom(raw)), - ), - method(IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, (raw) => { - const tabId = tabIdFrom(raw); - if (typeof raw !== "object" || raw === null) throw new Error("recording payload is required"); - const mimeType = "mimeType" in raw ? raw.mimeType : null; - const data = "data" in raw ? raw.data : null; - if (typeof mimeType !== "string" || !(data instanceof Uint8Array)) { - throw new Error("recording mimeType and bytes are required"); - } - return previewViewManager.saveRecording(tabId, mimeType, data); +}); + +export const captureScreenshot = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: DesktopPreviewScreenshotArtifactSchema, + handler: Effect.fn("desktop.ipc.preview.captureScreenshot")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.captureScreenshot(tabId); }), +}); + +export const revealArtifact = makeIpcMethod({ + channel: IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, + payload: DesktopPreviewArtifactInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.revealArtifact")(function* ({ path }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.revealArtifact(path); + }), +}); + +export const copyArtifactToClipboard = makeIpcMethod({ + channel: IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, + payload: DesktopPreviewArtifactInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.copyArtifactToClipboard")(function* ({ path }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.copyArtifactToClipboard(path); + }), +}); + +export const automationStatus = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: PreviewAutomationStatus, + handler: Effect.fn("desktop.ipc.preview.automationStatus")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.automationStatus(tabId); + }), +}); + +export const automationSnapshot = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: PreviewAutomationSnapshot, + handler: Effect.fn("desktop.ipc.preview.automationSnapshot")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.automationSnapshot(tabId); + }), +}); + +export const automationClick = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, + payload: DesktopPreviewAutomationClickInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationClick")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationClick(tabId, input); + }), +}); + +export const automationType = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, + payload: DesktopPreviewAutomationTypeInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationType")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationType(tabId, input); + }), +}); + +export const automationPress = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, + payload: DesktopPreviewAutomationPressInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationPress")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationPress(tabId, input); + }), +}); + +export const automationScroll = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, + payload: DesktopPreviewAutomationScrollInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationScroll")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationScroll(tabId, input); + }), +}); + +export const automationEvaluate = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, + payload: DesktopPreviewAutomationEvaluateInputSchema, + result: Schema.Unknown, + handler: Effect.fn("desktop.ipc.preview.automationEvaluate")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.automationEvaluate(tabId, input); + }), +}); + +export const automationWaitFor = makeIpcMethod({ + channel: IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, + payload: DesktopPreviewAutomationWaitForInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.automationWaitFor")(function* ({ tabId, input }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.automationWaitFor(tabId, input); + }), +}); + +export const saveRecording = makeIpcMethod({ + channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, + payload: DesktopPreviewRecordingSaveInputSchema, + result: DesktopPreviewRecordingArtifactSchema, + handler: Effect.fn("desktop.ipc.preview.saveRecording")(function* ({ tabId, mimeType, data }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.saveRecording(tabId, mimeType, data); + }), +}); + +export const methods = [ + createTab, + closeTab, + registerWebview, + navigate, + goBack, + goForward, + refresh, + zoomIn, + zoomOut, + resetZoom, + hardReload, + openDevTools, + clearCookies, + clearCache, + getPreviewConfig, + setAnnotationTheme, + pickElement, + cancelPickElement, + captureScreenshot, + revealArtifact, + copyArtifactToClipboard, + automationStatus, + automationSnapshot, + automationClick, + automationType, + automationPress, + automationScroll, + automationEvaluate, + automationWaitFor, + startRecording, + stopRecording, + saveRecording, ] as const; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9356eef441b642ae97be47447025cf9292cc382b..c7a16a5c7f5b39ecadf350c6abaef75f97ecdffb 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -44,6 +44,8 @@ import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; +import * as PreviewBrowserSession from "./preview/BrowserSession.ts"; +import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; const desktopEnvironmentLayer = Layer.unwrap( @@ -127,7 +129,15 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( Layer.provideMerge(desktopFoundationLayer), ); -const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopServerExposureLayer)); +const desktopPreviewLayer = PreviewManager.layer.pipe( + Layer.provideMerge(PreviewBrowserSession.layer), + Layer.provideMerge(desktopFoundationLayer), +); + +const desktopWindowLayer = DesktopWindow.layer.pipe( + Layer.provideMerge(desktopServerExposureLayer), + Layer.provideMerge(desktopPreviewLayer), +); const desktopBackendLayer = DesktopBackendManager.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), diff --git a/apps/desktop/src/picked-element-payload.test.ts b/apps/desktop/src/picked-element-payload.test.ts deleted file mode 100644 index 39537fa7c967b634ab863cb365d49dd49ef25683..0000000000000000000000000000000000000000 --- a/apps/desktop/src/picked-element-payload.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { isPickedElementPayload, isPreviewAnnotationPayload } from "./picked-element-payload.ts"; - -function validPayload(overrides?: Record): Record { - return { - pageUrl: "https://example.com/", - pageTitle: "Example", - tagName: "button", - selector: "button.submit", - htmlPreview: "", - componentName: "SubmitButton", - source: { - functionName: "SubmitButton", - fileName: "/repo/src/Button.tsx", - lineNumber: 12, - columnNumber: 5, - }, - stack: [ - { - functionName: "SubmitButton", - fileName: "/repo/src/Button.tsx", - lineNumber: 12, - columnNumber: 5, - }, - ], - styles: ".submit { color: white; }", - pickedAt: "2026-05-03T18:00:00.000Z", - ...overrides, - }; -} - -describe("isPickedElementPayload", () => { - it("accepts a complete, well-typed payload", () => { - expect(isPickedElementPayload(validPayload())).toBe(true); - }); - - it("accepts nullable string fields when null", () => { - expect( - isPickedElementPayload( - validPayload({ pageTitle: null, selector: null, componentName: null, source: null }), - ), - ).toBe(true); - }); - - it("accepts an empty stack array", () => { - expect(isPickedElementPayload(validPayload({ stack: [] }))).toBe(true); - }); - - it("accepts stack frames with null fields", () => { - expect( - isPickedElementPayload( - validPayload({ - stack: [ - { - functionName: null, - fileName: null, - lineNumber: null, - columnNumber: null, - }, - ], - }), - ), - ).toBe(true); - }); - - it("rejects null and primitive inputs", () => { - expect(isPickedElementPayload(null)).toBe(false); - expect(isPickedElementPayload(undefined)).toBe(false); - expect(isPickedElementPayload("string")).toBe(false); - expect(isPickedElementPayload(42)).toBe(false); - expect(isPickedElementPayload([])).toBe(false); - }); - - it.each<[string, Record]>([ - ["missing pageUrl", validPayload({ pageUrl: undefined })], - ["wrong-type pageUrl", validPayload({ pageUrl: 123 })], - ["missing tagName", validPayload({ tagName: undefined })], - ["missing htmlPreview", validPayload({ htmlPreview: undefined })], - ["missing styles", validPayload({ styles: undefined })], - ["missing pickedAt", validPayload({ pickedAt: undefined })], - ["wrong-type pageTitle", validPayload({ pageTitle: 99 })], - ["wrong-type selector", validPayload({ selector: 99 })], - ["wrong-type componentName", validPayload({ componentName: 99 })], - ])("rejects payloads with %s", (_label, value) => { - expect(isPickedElementPayload(value)).toBe(false); - }); - - it("rejects malformed source frames", () => { - expect( - isPickedElementPayload( - validPayload({ - source: { - functionName: 0, - fileName: null, - lineNumber: null, - columnNumber: null, - }, - }), - ), - ).toBe(false); - }); - - it("rejects non-finite numeric line/column numbers", () => { - expect( - isPickedElementPayload( - validPayload({ - source: { - functionName: null, - fileName: null, - lineNumber: Number.POSITIVE_INFINITY, - columnNumber: null, - }, - }), - ), - ).toBe(false); - expect( - isPickedElementPayload( - validPayload({ - source: { - functionName: null, - fileName: null, - lineNumber: Number.NaN, - columnNumber: null, - }, - }), - ), - ).toBe(false); - }); - - it("rejects malformed stack arrays", () => { - expect(isPickedElementPayload(validPayload({ stack: "not-an-array" }))).toBe(false); - expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false); - }); -}); - -function validAnnotation(overrides?: Record): Record { - return { - id: "annotation_1", - pageUrl: "https://example.com/", - pageTitle: "Example", - comment: "Make this clearer", - elements: [ - { - id: "element_1", - element: validPayload(), - rect: { x: 10, y: 20, width: 100, height: 40 }, - }, - ], - regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], - strokes: [ - { - id: "stroke_1", - color: "#7c3aed", - width: 4, - points: [ - { x: 10, y: 10 }, - { x: 20, y: 20 }, - ], - bounds: { x: 6, y: 6, width: 18, height: 18 }, - }, - ], - styleChanges: [ - { - targetId: "element_1", - selector: "button.submit", - property: "opacity", - previousValue: "1", - value: "0.5", - }, - ], - screenshot: null, - createdAt: "2026-06-11T00:00:00.000Z", - ...overrides, - }; -} - -describe("isPreviewAnnotationPayload", () => { - it("accepts a structured annotation draft before screenshot capture", () => { - expect(isPreviewAnnotationPayload(validAnnotation())).toBe(true); - }); - - it("rejects screenshots supplied by the guest preload", () => { - expect(isPreviewAnnotationPayload(validAnnotation({ screenshot: { dataUrl: "bad" } }))).toBe( - false, - ); - }); - - it("rejects malformed geometry and nested element payloads", () => { - expect( - isPreviewAnnotationPayload( - validAnnotation({ regions: [{ id: "region_1", rect: { x: 0, y: 0, width: "wide" } }] }), - ), - ).toBe(false); - expect( - isPreviewAnnotationPayload( - validAnnotation({ elements: [{ id: "element_1", element: {}, rect: {} }] }), - ), - ).toBe(false); - }); -}); diff --git a/apps/desktop/src/picked-element-payload.ts b/apps/desktop/src/picked-element-payload.ts deleted file mode 100644 index a9fadf63c8ff444fbc3c6b89edda07e83bd6dabb..0000000000000000000000000000000000000000 --- a/apps/desktop/src/picked-element-payload.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Strict structural validator for `PickedElementPayload` messages received - * from the in-page picker preload (`apps/desktop/src/preview-pick-preload.ts`) - * via `wc.ipc`. Lives in its own electron-free module so the validator is - * trivially unit-testable. - * - * Validation must be tight: downstream `normalizeElementContextSelection` - * calls `.trim()` on incoming strings, so a malformed payload (preload bug, - * future schema mismatch, malicious page that intercepts the preload's IPC - * channel via prototype pollution) would otherwise throw deep in the - * renderer and the chip silently never appears. - */ -import type { PickedElementPayload, PreviewAnnotationPayload } from "@t3tools/contracts"; - -function isStringOrNull(value: unknown): value is string | null { - return value === null || typeof value === "string"; -} - -function isFiniteNumberOrNull(value: unknown): value is number | null { - return value === null || (typeof value === "number" && Number.isFinite(value)); -} - -function isPickedStackFrame(value: unknown): boolean { - if (typeof value !== "object" || value === null) return false; - const frame = value as Record; - return ( - isStringOrNull(frame["functionName"]) && - isStringOrNull(frame["fileName"]) && - isFiniteNumberOrNull(frame["lineNumber"]) && - isFiniteNumberOrNull(frame["columnNumber"]) - ); -} - -export function isPickedElementPayload(value: unknown): value is PickedElementPayload { - if (typeof value !== "object" || value === null) return false; - const c = value as Record; - if (typeof c["pageUrl"] !== "string") return false; - if (typeof c["tagName"] !== "string") return false; - if (typeof c["htmlPreview"] !== "string") return false; - if (typeof c["styles"] !== "string") return false; - if (typeof c["pickedAt"] !== "string") return false; - if (!isStringOrNull(c["pageTitle"])) return false; - if (!isStringOrNull(c["selector"])) return false; - if (!isStringOrNull(c["componentName"])) return false; - if (c["source"] !== null && !isPickedStackFrame(c["source"])) return false; - if (!Array.isArray(c["stack"])) return false; - if (!c["stack"].every(isPickedStackFrame)) return false; - return true; -} - -function isRect(value: unknown): boolean { - if (typeof value !== "object" || value === null) return false; - const rect = value as Record; - return ["x", "y", "width", "height"].every( - (key) => typeof rect[key] === "number" && Number.isFinite(rect[key]), - ); -} - -function isPoint(value: unknown): boolean { - if (typeof value !== "object" || value === null) return false; - const point = value as Record; - return ( - typeof point["x"] === "number" && - Number.isFinite(point["x"]) && - typeof point["y"] === "number" && - Number.isFinite(point["y"]) - ); -} - -export function isPreviewAnnotationPayload(value: unknown): value is PreviewAnnotationPayload { - if (typeof value !== "object" || value === null) return false; - const annotation = value as Record; - if (typeof annotation["id"] !== "string") return false; - if (typeof annotation["pageUrl"] !== "string") return false; - if (!isStringOrNull(annotation["pageTitle"])) return false; - if (typeof annotation["comment"] !== "string") return false; - if (typeof annotation["createdAt"] !== "string") return false; - if (annotation["screenshot"] !== null) return false; - - const elements = annotation["elements"]; - if (!Array.isArray(elements)) return false; - if ( - !elements.every((entry) => { - if (typeof entry !== "object" || entry === null) return false; - const target = entry as Record; - return ( - typeof target["id"] === "string" && - isPickedElementPayload(target["element"]) && - isRect(target["rect"]) - ); - }) - ) { - return false; - } - - const regions = annotation["regions"]; - if (!Array.isArray(regions)) return false; - if ( - !regions.every((entry) => { - if (typeof entry !== "object" || entry === null) return false; - const target = entry as Record; - return typeof target["id"] === "string" && isRect(target["rect"]); - }) - ) { - return false; - } - - const strokes = annotation["strokes"]; - if (!Array.isArray(strokes)) return false; - if ( - !strokes.every((entry) => { - if (typeof entry !== "object" || entry === null) return false; - const target = entry as Record; - return ( - typeof target["id"] === "string" && - typeof target["color"] === "string" && - typeof target["width"] === "number" && - Number.isFinite(target["width"]) && - Array.isArray(target["points"]) && - target["points"].every(isPoint) && - isRect(target["bounds"]) - ); - }) - ) { - return false; - } - - const styleChanges = annotation["styleChanges"]; - if (!Array.isArray(styleChanges)) return false; - if ( - !styleChanges.every((entry) => { - if (typeof entry !== "object" || entry === null) return false; - const change = entry as Record; - return ( - typeof change["targetId"] === "string" && - isStringOrNull(change["selector"]) && - typeof change["property"] === "string" && - typeof change["previousValue"] === "string" && - typeof change["value"] === "string" - ); - }) - ) { - return false; - } - return true; -} diff --git a/apps/desktop/src/playwright-injected-runtime.test.ts b/apps/desktop/src/playwright-injected-runtime.test.ts deleted file mode 100644 index 20692063672b4e535ccaa236733577f9eedc763b..0000000000000000000000000000000000000000 --- a/apps/desktop/src/playwright-injected-runtime.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - playwrightInjectedRuntimeInstallExpression, - playwrightInjectedRuntimeSource, -} from "./playwright-injected-runtime.ts"; - -describe("playwright injected runtime", () => { - it("extracts the pinned runtime from playwright-core", async () => { - const source = await playwrightInjectedRuntimeSource(); - expect(source.length).toBeGreaterThan(100_000); - expect(source).toContain("InjectedScript"); - }); - - it("builds an idempotent install expression", async () => { - const expression = await playwrightInjectedRuntimeInstallExpression(); - expect(expression).toContain("__t3PlaywrightInjected"); - expect(expression).toContain('testIdAttributeName":"data-testid'); - }); -}); diff --git a/apps/desktop/src/playwright-injected-runtime.ts b/apps/desktop/src/playwright-injected-runtime.ts deleted file mode 100644 index d4d7c6bd9402227e77763d866ba5cfa347bd8544..0000000000000000000000000000000000000000 --- a/apps/desktop/src/playwright-injected-runtime.ts +++ /dev/null @@ -1,49 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; -import { readFile } from "node:fs/promises"; -import { runInNewContext } from "node:vm"; - -const require = createRequire(import.meta.url); -let sourcePromise: Promise | null = null; - -export const playwrightInjectedRuntimeSource = (): Promise => { - sourcePromise ??= (async () => { - const packageJsonPath = require.resolve("playwright-core/package.json"); - const coreBundle = await readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8"); - const marker = "source3 = "; - const start = coreBundle.indexOf(marker); - if (start < 0) throw new Error("Playwright injected runtime marker was not found."); - const literalStart = start + marker.length; - const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart); - if (literalEnd < 0) throw new Error("Playwright injected runtime terminator was not found."); - const literal = coreBundle.slice(literalStart, literalEnd); - const source = runInNewContext(literal, Object.create(null), { timeout: 1_000 }); - if (typeof source !== "string" || source.length < 100_000) { - throw new Error("Playwright injected runtime extraction returned invalid source."); - } - return source; - })(); - return sourcePromise; -}; - -export const playwrightInjectedRuntimeInstallExpression = async (): Promise => { - const source = await playwrightInjectedRuntimeSource(); - const options = { - isUnderTest: false, - sdkLanguage: "javascript", - testIdAttributeName: "data-testid", - stableRafCount: 1, - browserName: "chromium", - shouldPrependErrorPrefix: false, - isUtilityWorld: false, - customEngines: [], - }; - return `(() => { - if (globalThis.__t3PlaywrightInjected) return true; - const module = { exports: {} }; - ${source} - globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${JSON.stringify(options)}); - return true; - })()`; -}; diff --git a/apps/desktop/src/preview-annotation-styles.generated.ts b/apps/desktop/src/preview-annotation-styles.generated.ts deleted file mode 100644 index 5b6b73c8ba7823822b484271ff16a69556825567..0000000000000000000000000000000000000000 --- a/apps/desktop/src/preview-annotation-styles.generated.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Generated by scripts/build-preview-annotation-css.mjs. Do not edit. -export const previewAnnotationStyles = - '/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: calc(var(--spacing) * 0);\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: calc(var(--spacing) * 0);\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: calc(var(--spacing) * 1);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: calc(var(--spacing) * 0);\n}\n.p-1 {\n padding: calc(var(--spacing) * 1);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: calc(var(--spacing) * 0);\n}\n.px-1 {\n padding-inline: calc(var(--spacing) * 1);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: calc(var(--spacing) * 1);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; diff --git a/apps/desktop/src/preview-annotation.css b/apps/desktop/src/preview-annotation.css deleted file mode 100644 index 89676a22d585b21417f195fbc09e864a1e0c102a..0000000000000000000000000000000000000000 --- a/apps/desktop/src/preview-annotation.css +++ /dev/null @@ -1,68 +0,0 @@ -@import "tailwindcss"; - -@theme inline { - --font-sans: var(--t3-font-sans); - --font-mono: var(--t3-font-mono); - --color-background: var(--t3-background); - --color-foreground: var(--t3-foreground); - --color-popover: var(--t3-popover); - --color-popover-foreground: var(--t3-popover-foreground); - --color-primary: var(--t3-primary); - --color-primary-foreground: var(--t3-primary-foreground); - --color-muted: var(--t3-muted); - --color-muted-foreground: var(--t3-muted-foreground); - --color-accent: var(--t3-accent); - --color-accent-foreground: var(--t3-accent-foreground); - --color-border: var(--t3-border); - --color-input: var(--t3-input); - --color-ring: var(--t3-ring); - --radius-sm: calc(var(--t3-radius) - 4px); - --radius-md: calc(var(--t3-radius) - 2px); - --radius-lg: var(--t3-radius); - --radius-xl: calc(var(--t3-radius) + 4px); - --radius-2xl: calc(var(--t3-radius) + 8px); -} - -:host { - --t3-font-sans: - "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, - sans-serif; - --t3-font-mono: - "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; - --t3-radius: 0.625rem; - --t3-background: white; - --t3-foreground: oklch(0.269 0 0); - --t3-popover: white; - --t3-popover-foreground: oklch(0.269 0 0); - --t3-primary: oklch(0.488 0.217 264); - --t3-primary-foreground: white; - --t3-muted: rgb(0 0 0 / 4%); - --t3-muted-foreground: oklch(0.556 0 0); - --t3-accent: rgb(0 0 0 / 4%); - --t3-accent-foreground: oklch(0.269 0 0); - --t3-border: rgb(0 0 0 / 8%); - --t3-input: rgb(0 0 0 / 10%); - --t3-ring: oklch(0.488 0.217 264); - color: var(--t3-foreground); - font-family: var(--t3-font-sans); -} - -* { - box-sizing: border-box; - border-color: var(--t3-border); -} - -button, -input, -select, -textarea { - font: inherit; -} - -button:focus-visible, -input:focus-visible, -select:focus-visible, -textarea:focus-visible { - outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent); - outline-offset: 1px; -} diff --git a/apps/desktop/src/preview-pick-label-position.ts b/apps/desktop/src/preview-pick-label-position.ts deleted file mode 100644 index e07f64f13d5cb371d0132c32f3838de2a4ed4ec6..0000000000000000000000000000000000000000 --- a/apps/desktop/src/preview-pick-label-position.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Pure clamp/flip math for the floating label that follows the cursor while - * the user is picking an element in the in-app browser. Lives in its own - * electron-free module so the geometry can be unit-tested without spinning - * up an Electron preload context (`preview-pick-preload.ts` itself imports - * `electron` and `react-grab/primitives`, which can't load under vitest). - * - * - Horizontally pins the label to `targetLeft`, clamped into - * `[VIEWPORT_MARGIN, viewportWidth - labelWidth - VIEWPORT_MARGIN]`. - * - Vertically prefers above the target. If the label would overflow the - * top, flips below; if THAT also overflows the bottom, pins to the - * bottom margin (better to overlap the highlight than disappear). - */ - -/** Distance in CSS pixels between the highlight and the floating label. */ -export const LABEL_GAP = 4; -/** Minimum padding the label keeps from any viewport edge. */ -export const VIEWPORT_MARGIN = 4; - -export function computeLabelPosition(input: { - targetLeft: number; - targetTop: number; - targetBottom: number; - labelWidth: number; - labelHeight: number; - viewportWidth: number; - viewportHeight: number; -}): { x: number; y: number } { - const { targetLeft, targetTop, targetBottom, labelWidth, labelHeight } = input; - const { viewportWidth, viewportHeight } = input; - - let x = targetLeft; - const maxX = viewportWidth - labelWidth - VIEWPORT_MARGIN; - if (x > maxX) x = maxX; - if (x < VIEWPORT_MARGIN) x = VIEWPORT_MARGIN; - - let y = targetTop - labelHeight - LABEL_GAP; - if (y < VIEWPORT_MARGIN) { - y = targetBottom + LABEL_GAP; - if (y + labelHeight > viewportHeight - VIEWPORT_MARGIN) { - y = Math.max(VIEWPORT_MARGIN, viewportHeight - labelHeight - VIEWPORT_MARGIN); - } - } - - return { x, y }; -} diff --git a/apps/desktop/src/preview-pick-preload.test.ts b/apps/desktop/src/preview-pick-preload.test.ts deleted file mode 100644 index 9f239e1d2e86a0cbecd1125bd99af4391d811c12..0000000000000000000000000000000000000000 --- a/apps/desktop/src/preview-pick-preload.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { computeLabelPosition } from "./preview-pick-label-position.ts"; - -const VIEWPORT = { viewportWidth: 1280, viewportHeight: 800 }; - -describe("computeLabelPosition", () => { - it("anchors to the element's top-left when there's room above and to the right", () => { - const { x, y } = computeLabelPosition({ - ...VIEWPORT, - targetLeft: 200, - targetTop: 200, - targetBottom: 240, - labelWidth: 120, - labelHeight: 18, - }); - expect(x).toBe(200); - // 200 (top) - 18 (height) - 4 (gap) - expect(y).toBe(200 - 18 - 4); - }); - - it("clamps left edge so the label stays inside the viewport", () => { - const { x } = computeLabelPosition({ - ...VIEWPORT, - targetLeft: -50, - targetTop: 200, - targetBottom: 240, - labelWidth: 120, - labelHeight: 18, - }); - expect(x).toBe(4); - }); - - it("clamps right edge when the label would overflow the viewport (the bug we shipped)", () => { - const { x } = computeLabelPosition({ - ...VIEWPORT, - targetLeft: 1240, - targetTop: 200, - targetBottom: 240, - labelWidth: 200, - labelHeight: 18, - }); - // viewportWidth (1280) - labelWidth (200) - margin (4) = 1076 - expect(x).toBe(1076); - }); - - it("flips the label below the element when there's no room above", () => { - const { y } = computeLabelPosition({ - ...VIEWPORT, - targetLeft: 200, - targetTop: 4, - targetBottom: 44, - labelWidth: 120, - labelHeight: 18, - }); - // labelY = 4 - 18 - 4 = -18 → flip → 44 + 4 = 48 - expect(y).toBe(48); - }); - - it("pins to the bottom margin when the element fills the viewport (no room above OR below)", () => { - const { y } = computeLabelPosition({ - ...VIEWPORT, - targetLeft: 200, - targetTop: 0, - targetBottom: 800, - labelWidth: 120, - labelHeight: 18, - }); - // Above overflows top → flip below = 800 + 4 = 804 → also overflows - // bottom → pin to viewportHeight - labelHeight - margin = 778. - expect(y).toBe(800 - 18 - 4); - }); - - it("never returns a negative coordinate", () => { - const { x, y } = computeLabelPosition({ - ...VIEWPORT, - targetLeft: -1000, - targetTop: -1000, - targetBottom: -900, - labelWidth: 5000, - labelHeight: 5000, - }); - expect(x).toBeGreaterThanOrEqual(0); - expect(y).toBeGreaterThanOrEqual(0); - }); -}); diff --git a/apps/desktop/src/preview-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts index 2fda1abd72009be89fed32eaf7dec1ddfb603b96..84e6abb29ee6beb4f59fdb1cfebab7b84089fd19 100644 --- a/apps/desktop/src/preview-pick-preload.ts +++ b/apps/desktop/src/preview-pick-preload.ts @@ -1,1262 +1 @@ -// @effect-diagnostics globalDate:off -import { ipcRenderer } from "electron"; -import { getElementContext } from "react-grab/primitives"; -import type { - DesktopPreviewAnnotationTheme, - PickedElementPayload, - PickedElementStackFrame, - PreviewAnnotationPayload, - PreviewAnnotationPoint, - PreviewAnnotationRect, - PreviewAnnotationRegionTarget, - PreviewAnnotationStrokeTarget, - PreviewAnnotationStyleChange, -} from "@t3tools/contracts"; - -import { previewAnnotationStyles } from "./preview-annotation-styles.generated.ts"; - -const START_PICK_CHANNEL = "preview:start-pick"; -const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; -const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; -const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; -const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; -const HUMAN_INPUT_CHANNEL = "preview:human-input"; -const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; -const Z_INDEX_OVERLAY = 2147483646; -const PRIMARY = "var(--t3-primary)"; -const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; -const MAX_MARQUEE_ELEMENTS = 20; -const CONTENT_LAYER_Z_INDEX = 1; -const CHROME_LAYER_Z_INDEX = 10; - -type AnnotationTool = "select" | "marquee" | "draw" | "erase"; - -interface SelectedElement { - id: string; - element: Element; - outline: HTMLDivElement; - label: HTMLDivElement; - baselineStyles: Map; -} - -interface AnnotationSession { - teardown: (notifyMain: boolean) => void; - applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; -} - -let activeSession: AnnotationSession | null = null; -let idSequence = 0; -let annotationTheme: DesktopPreviewAnnotationTheme | null = null; - -const applyAnnotationTheme = ( - host: HTMLElement, - theme: DesktopPreviewAnnotationTheme | null, -): void => { - if (!theme) return; - host.style.colorScheme = theme.colorScheme; - const variables = { - "--t3-radius": theme.radius, - "--t3-background": theme.background, - "--t3-foreground": theme.foreground, - "--t3-popover": theme.popover, - "--t3-popover-foreground": theme.popoverForeground, - "--t3-primary": theme.primary, - "--t3-primary-foreground": theme.primaryForeground, - "--t3-muted": theme.muted, - "--t3-muted-foreground": theme.mutedForeground, - "--t3-accent": theme.accent, - "--t3-accent-foreground": theme.accentForeground, - "--t3-border": theme.border, - "--t3-input": theme.input, - "--t3-ring": theme.ring, - "--t3-font-sans": theme.fontSans, - "--t3-font-mono": theme.fontMono, - }; - for (const [name, value] of Object.entries(variables)) { - host.style.setProperty(name, value); - } -}; - -const reportHumanPointerInput = (event: PointerEvent): void => { - if (!event.isTrusted) return; - ipcRenderer.send(HUMAN_INPUT_CHANNEL, { - kind: "pointer", - x: event.clientX, - y: event.clientY, - button: event.button, - }); -}; - -const reportHumanKeyInput = (event: KeyboardEvent): void => { - if (!event.isTrusted) return; - ipcRenderer.send(HUMAN_INPUT_CHANNEL, { - kind: "key", - key: event.key, - code: event.code, - }); -}; - -window.addEventListener("pointerdown", reportHumanPointerInput, true); -window.addEventListener("keydown", reportHumanKeyInput, true); - -const nextId = (prefix: string): string => { - idSequence += 1; - return `${prefix}_${idSequence.toString(36)}`; -}; - -const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({ - x: rect.left, - y: rect.top, - width: rect.width, - height: rect.height, -}); - -const normalizeRect = ( - startX: number, - startY: number, - endX: number, - endY: number, -): PreviewAnnotationRect => ({ - x: Math.min(startX, endX), - y: Math.min(startY, endY), - width: Math.abs(endX - startX), - height: Math.abs(endY - startY), -}); - -const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; - -function unionRects( - rects: ReadonlyArray, - padding = 20, -): PreviewAnnotationRect | null { - if (rects.length === 0) return null; - const left = Math.min(...rects.map((rect) => rect.x)); - const top = Math.min(...rects.map((rect) => rect.y)); - const right = Math.max(...rects.map((rect) => rect.x + rect.width)); - const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); - const x = Math.max(0, left - padding); - const y = Math.max(0, top - padding); - const maxWidth = Math.max(1, window.innerWidth - x); - const maxHeight = Math.max(1, window.innerHeight - y); - return { - x, - y, - width: Math.min(maxWidth, right - left + padding * 2), - height: Math.min(maxHeight, bottom - top + padding * 2), - }; -} - -function isAnnotationNode(element: Element): boolean { - return element instanceof Element && element.closest(`[${OVERLAY_ATTRIBUTE}]`) !== null; -} - -function pickFromPoint(clientX: number, clientY: number): Element | null { - for (const candidate of document.elementsFromPoint(clientX, clientY)) { - if (!(candidate instanceof Element)) continue; - if (isAnnotationNode(candidate)) continue; - if (candidate === document.documentElement || candidate === document.body) continue; - return candidate; - } - return null; -} - -function describeRawElement(element: Element): string { - const tag = element.tagName.toLowerCase(); - const id = element.id ? `#${element.id}` : ""; - const classes = - element instanceof HTMLElement && typeof element.className === "string" - ? element.className - .trim() - .split(/\s+/) - .filter(Boolean) - .slice(0, 2) - .map((name) => `.${name}`) - .join("") - : ""; - return `${tag}${id}${classes}`; -} - -function createBox(color: string, fill: string): HTMLDivElement { - const node = document.createElement("div"); - node.setAttribute(OVERLAY_ATTRIBUTE, ""); - node.style.cssText = [ - "position:fixed", - "pointer-events:none", - `border:2px solid ${color}`, - `background:${fill}`, - "border-radius:3px", - "box-sizing:border-box", - "display:none", - `z-index:${CONTENT_LAYER_Z_INDEX}`, - ].join(";"); - return node; -} - -function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { - node.style.display = "block"; - node.style.transform = `translate(${rect.x}px, ${rect.y}px)`; - node.style.width = `${rect.width}px`; - node.style.height = `${rect.height}px`; -} - -function createLabel(): HTMLDivElement { - const label = document.createElement("div"); - label.setAttribute(OVERLAY_ATTRIBUTE, ""); - label.className = - "fixed z-1 max-w-70 overflow-hidden rounded-md bg-primary px-2 py-1 font-sans text-xs font-semibold text-primary-foreground shadow-md"; - label.style.cssText = [ - "position:fixed", - "pointer-events:none", - "white-space:nowrap", - "text-overflow:ellipsis", - `z-index:${CONTENT_LAYER_Z_INDEX}`, - ].join(";"); - return label; -} - -function updateSelectedVisual(target: SelectedElement): void { - if (!target.element.isConnected) { - target.outline.style.display = "none"; - target.label.style.display = "none"; - return; - } - const rect = target.element.getBoundingClientRect(); - positionBox(target.outline, rectFromDomRect(rect)); - target.label.textContent = describeRawElement(target.element); - target.label.style.display = "block"; - target.label.style.transform = `translate(${Math.max(4, rect.left)}px, ${Math.max(4, rect.top - 22)}px)`; -} - -function toStackFrame(frame: { - functionName?: string; - fileName?: string; - lineNumber?: number; - columnNumber?: number; -}): PickedElementStackFrame { - return { - functionName: frame.functionName ?? null, - fileName: frame.fileName ?? null, - lineNumber: frame.lineNumber ?? null, - columnNumber: frame.columnNumber ?? null, - }; -} - -async function captureElement(element: Element): Promise { - try { - const context = await getElementContext(element); - const stack = (context.stack ?? []).map(toStackFrame); - return { - pageUrl: location.href, - pageTitle: document.title?.trim() || null, - tagName: element.tagName.toLowerCase(), - selector: context.selector, - htmlPreview: context.htmlPreview ?? "", - componentName: context.componentName, - source: stack[0] ?? null, - stack, - styles: context.styles ?? "", - pickedAt: new Date().toISOString(), - }; - } catch { - return null; - } -} - -function createButton(label: string, title: string): HTMLButtonElement { - const button = document.createElement("button"); - button.type = "button"; - button.textContent = label; - button.title = title; - button.className = - "inline-flex h-7 cursor-pointer items-center justify-center rounded-md border border-transparent px-2 font-sans text-xs font-medium text-foreground outline-none hover:bg-accent disabled:pointer-events-none disabled:opacity-60"; - return button; -} - -function styleControl(input: HTMLInputElement | HTMLSelectElement): void { - input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value"); - input.className = - "h-7 min-w-0 w-full appearance-none rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground shadow-xs outline-none"; -} - -function createUnitControl(input: HTMLInputElement): HTMLElement { - const wrapper = document.createElement("div"); - wrapper.style.cssText = "position:relative;min-width:0"; - const unit = document.createElement("span"); - unit.textContent = input.dataset.unit ?? ""; - unit.className = - "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground"; - wrapper.append(input, unit); - return wrapper; -} - -function createField( - labelText: string, - input: HTMLInputElement | HTMLSelectElement, -): HTMLLabelElement { - const label = document.createElement("label"); - label.className = - "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; - const text = document.createElement("span"); - text.textContent = labelText; - styleControl(input); - label.append( - text, - input instanceof HTMLInputElement && input.dataset.unit ? createUnitControl(input) : input, - ); - return label; -} - -function createStyleSection(): HTMLElement { - const section = document.createElement("section"); - section.className = "grid gap-1 border-t border-border py-2"; - return section; -} - -function createUnitInput(unit: string, placeholder = "0"): HTMLInputElement { - const input = document.createElement("input"); - input.type = "number"; - input.placeholder = placeholder; - input.style.paddingRight = "30px"; - input.dataset.unit = unit; - return input; -} - -function pathFromPoints(points: ReadonlyArray): string { - if (points.length === 0) return ""; - if (points.length === 1) return `M ${points[0]!.x} ${points[0]!.y} l 0.01 0.01`; - let path = `M ${points[0]!.x} ${points[0]!.y}`; - for (let index = 1; index < points.length - 1; index += 1) { - const current = points[index]!; - const next = points[index + 1]!; - path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`; - } - const last = points[points.length - 1]!; - path += ` L ${last.x} ${last.y}`; - return path; -} - -function strokeBounds( - points: ReadonlyArray, - width: number, -): PreviewAnnotationRect { - const xs = points.map((point) => point.x); - const ys = points.map((point) => point.y); - const padding = width + 3; - const left = Math.min(...xs) - padding; - const top = Math.min(...ys) - padding; - const right = Math.max(...xs) + padding; - const bottom = Math.max(...ys) + padding; - return { x: left, y: top, width: right - left, height: bottom - top }; -} - -function startAnnotation(): void { - activeSession?.teardown(false); - let finished = false; - const host = document.createElement("div"); - host.setAttribute(OVERLAY_ATTRIBUTE, ""); - host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`; - applyAnnotationTheme(host, annotationTheme); - const shadowRoot = host.attachShadow({ mode: "closed" }); - const themeStyle = document.createElement("style"); - themeStyle.textContent = previewAnnotationStyles; - shadowRoot.appendChild(themeStyle); - - const root = document.createElement("div"); - root.setAttribute(OVERLAY_ATTRIBUTE, ""); - root.className = "fixed inset-0 font-sans text-foreground"; - root.style.cssText = "pointer-events:none"; - const cursorStyle = document.createElement("style"); - cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, ""); - cursorStyle.textContent = `html[data-t3code-annotation-tool] body, html[data-t3code-annotation-tool] body * { cursor: crosshair !important; } [${OVERLAY_ATTRIBUTE}], [${OVERLAY_ATTRIBUTE}] * { cursor: default !important; } [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-inner-spin-button, [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-outer-spin-button { appearance:none; margin:0; }`; - document.documentElement.appendChild(cursorStyle); - shadowRoot.appendChild(root); - - const hoverOutline = createBox(PRIMARY, PRIMARY_FILL); - const marqueeBox = createBox(PRIMARY, PRIMARY_FILL); - root.append(hoverOutline, marqueeBox); - - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.setAttribute(OVERLAY_ATTRIBUTE, ""); - svg.setAttribute("width", "100%"); - svg.setAttribute("height", "100%"); - svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); - svg.style.cssText = "position:fixed;inset:0;overflow:visible;pointer-events:none"; - svg.style.zIndex = String(CONTENT_LAYER_Z_INDEX); - root.appendChild(svg); - - const toolbar = document.createElement("div"); - toolbar.setAttribute(OVERLAY_ATTRIBUTE, ""); - toolbar.className = - "pointer-events-auto fixed top-2.5 left-1/2 flex -translate-x-1/2 gap-0.5 rounded-lg border border-border bg-popover/95 p-1 text-popover-foreground shadow-lg backdrop-blur-xl"; - toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX); - root.appendChild(toolbar); - - const editor = document.createElement("div"); - editor.setAttribute(OVERLAY_ATTRIBUTE, ""); - editor.className = - "pointer-events-auto fixed hidden max-h-[calc(100vh-16px)] w-[min(360px,calc(100vw-16px))] flex-col overflow-hidden rounded-xl border border-border bg-popover/96 text-popover-foreground shadow-2xl backdrop-blur-xl"; - editor.style.zIndex = String(CHROME_LAYER_Z_INDEX); - root.appendChild(editor); - - const composerRow = document.createElement("div"); - composerRow.className = "flex items-start gap-2 p-2"; - - const adjust = createButton("", "Expand annotation editor"); - adjust.setAttribute("aria-label", "Expand annotation editor"); - adjust.setAttribute("aria-expanded", "false"); - adjust.className += - " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"; - adjust.innerHTML = - ''; - composerRow.appendChild(adjust); - - const comment = document.createElement("textarea"); - comment.placeholder = "Describe the change…"; - comment.rows = 1; - comment.className = - "min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0"; - composerRow.appendChild(comment); - - const dragHandle = document.createElement("button"); - dragHandle.type = "button"; - dragHandle.textContent = "⠿"; - dragHandle.title = "Drag annotation editor"; - dragHandle.className = - "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground"; - composerRow.appendChild(dragHandle); - - const submit = createButton("Attach", "Attach annotation and screenshot"); - submit.className += - " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; - composerRow.appendChild(submit); - editor.appendChild(composerRow); - - const stylePanel = document.createElement("div"); - stylePanel.className = - "hidden max-h-[min(176px,calc(100vh-180px))] overflow-auto border-t border-border bg-muted/40 px-3"; - editor.appendChild(stylePanel); - - const selected = new Map(); - const regions: PreviewAnnotationRegionTarget[] = []; - const strokes: PreviewAnnotationStrokeTarget[] = []; - const styleChanges = new Map(); - const toolButtons = new Map(); - let tool: AnnotationTool = "select"; - let dragStart: PreviewAnnotationPoint | null = null; - let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; - let pendingCapture = false; - let editorExpanded = false; - let editorWasShown = false; - let editorPosition: { left: number; top: number } | null = null; - let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; - let editorLayoutFrame: number | null = null; - - const resizeComment = (): void => { - const maxHeight = 96; - comment.style.height = "auto"; - const nextHeight = Math.min(comment.scrollHeight, maxHeight); - comment.style.height = `${nextHeight}px`; - comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden"; - queueEditorLayout(); - }; - comment.addEventListener("input", resizeComment); - - const updateStatus = (): void => { - const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0; - editor.style.display = hasTargets ? "flex" : "none"; - submit.disabled = !hasTargets; - submit.style.opacity = hasTargets ? "1" : "0.45"; - adjust.disabled = !hasTargets; - stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none"; - queueEditorLayout(); - if (hasTargets && !editorWasShown) { - editorWasShown = true; - window.setTimeout(() => comment.focus({ preventScroll: true }), 0); - } - }; - - const refreshToolButtons = (): void => { - for (const [candidate, button] of toolButtons) { - const active = candidate === tool; - button.classList.toggle("bg-primary/10", active); - button.classList.toggle("text-primary", active); - button.classList.toggle("text-foreground", !active); - } - if (tool !== "select") hoverOutline.style.display = "none"; - if (tool !== "marquee") marqueeBox.style.display = "none"; - document.documentElement.setAttribute("data-t3code-annotation-tool", tool); - }; - - const removeSelected = (target: SelectedElement): void => { - if (target.element instanceof HTMLElement || target.element instanceof SVGElement) { - for (const [property, baseline] of target.baselineStyles) { - if (baseline) target.element.style.setProperty(property, baseline); - else target.element.style.removeProperty(property); - } - } - selected.delete(target.element); - target.outline.remove(); - target.label.remove(); - for (const [key, change] of styleChanges) { - if (change.targetId === target.id) styleChanges.delete(key); - } - updateStatus(); - }; - - const addSelected = (element: Element): void => { - if (selected.has(element)) return; - const target: SelectedElement = { - id: nextId("element"), - element, - outline: createBox(PRIMARY, PRIMARY_FILL), - label: createLabel(), - baselineStyles: new Map(), - }; - selected.set(element, target); - root.append(target.outline, target.label); - updateSelectedVisual(target); - updateStatus(); - if (editorExpanded) { - stylePanel.style.display = "grid"; - syncStyleControls(); - } - }; - - const toggleSelected = (element: Element, additive: boolean): void => { - const existing = selected.get(element); - if (existing) { - removeSelected(existing); - return; - } - if (!additive) { - for (const target of Array.from(selected.values())) removeSelected(target); - } - addSelected(element); - }; - - const setStyleForSelected = (property: string, value: string): void => { - for (const target of selected.values()) { - if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) - continue; - if (!target.baselineStyles.has(property)) { - target.baselineStyles.set(property, target.element.style.getPropertyValue(property)); - } - const key = `${target.id}:${property}`; - const previousValue = - styleChanges.get(key)?.previousValue ?? - getComputedStyle(target.element).getPropertyValue(property).trim(); - target.element.style.setProperty(property, value, "important"); - styleChanges.set(key, { - targetId: target.id, - selector: null, - property, - previousValue, - value, - }); - updateSelectedVisual(target); - } - }; - - const textSection = createStyleSection(); - const colorsSection = createStyleSection(); - const bordersSection = createStyleSection(); - const sizingSection = createStyleSection(); - stylePanel.append(textSection, colorsSection, bordersSection, sizingSection); - - const fontFamily = document.createElement("select"); - for (const value of ["inherit", "system-ui", "sans-serif", "serif", "monospace"]) { - const option = document.createElement("option"); - option.value = value; - option.textContent = value; - fontFamily.appendChild(option); - } - fontFamily.addEventListener("change", () => setStyleForSelected("font-family", fontFamily.value)); - textSection.appendChild(createField("Font", fontFamily)); - - const fontSize = createUnitInput("px", "16"); - fontSize.min = "1"; - fontSize.max = "300"; - fontSize.addEventListener("input", () => { - if (fontSize.value) setStyleForSelected("font-size", `${fontSize.value}px`); - }); - textSection.appendChild(createField("Font size", fontSize)); - - const fontWeight = document.createElement("select"); - for (const value of ["300", "400", "500", "600", "700", "800", "900"]) { - const option = document.createElement("option"); - option.value = value; - option.textContent = value; - fontWeight.appendChild(option); - } - fontWeight.addEventListener("change", () => setStyleForSelected("font-weight", fontWeight.value)); - textSection.appendChild(createField("Font weight", fontWeight)); - - const lineHeight = document.createElement("input"); - lineHeight.type = "text"; - lineHeight.placeholder = "normal / 1.4"; - lineHeight.addEventListener("change", () => { - if (lineHeight.value.trim()) setStyleForSelected("line-height", lineHeight.value.trim()); - }); - textSection.appendChild(createField("Line height", lineHeight)); - - const createColorRow = ( - labelText: string, - property: string, - section: HTMLElement, - ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => { - const row = document.createElement("label"); - row.className = - "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; - const label = document.createElement("span"); - label.textContent = labelText; - const control = document.createElement("div"); - control.className = - "grid h-7 grid-cols-[22px_minmax(0,1fr)] items-center gap-1 rounded-md border border-input bg-background px-1 shadow-xs"; - const color = document.createElement("input"); - color.type = "color"; - color.setAttribute("aria-label", labelText); - color.style.cssText = - "width:20px;height:20px;padding:0;border:0;border-radius:5px;overflow:hidden;background:transparent;cursor:pointer"; - const text = document.createElement("input"); - text.type = "text"; - text.setAttribute("aria-label", `${labelText} value`); - text.className = - "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none"; - color.addEventListener("input", () => { - text.value = color.value; - setStyleForSelected(property, color.value); - }); - text.addEventListener("change", () => { - const value = text.value.trim(); - if (!value) return; - setStyleForSelected(property, value); - if (/^#[0-9a-f]{6}$/i.test(value)) color.value = value; - }); - control.append(color, text); - row.append(label, control); - section.appendChild(row); - return { row, color, text }; - }; - - const textColor = createColorRow("Text color", "color", colorsSection); - const backgroundColor = createColorRow("Background", "background-color", colorsSection); - - const opacity = document.createElement("input"); - opacity.type = "range"; - opacity.min = "0"; - opacity.max = "1"; - opacity.step = "0.05"; - opacity.value = "1"; - opacity.style.accentColor = PRIMARY; - opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value)); - colorsSection.appendChild(createField("Opacity", opacity)); - - const radius = createUnitInput("px", "0"); - radius.min = "0"; - radius.max = "300"; - radius.addEventListener("input", () => { - if (radius.value) setStyleForSelected("border-radius", `${radius.value}px`); - }); - bordersSection.appendChild(createField("Radius", radius)); - - const borderColor = createColorRow("Border color", "border-color", bordersSection); - - const borderWidth = createUnitInput("px", "0"); - borderWidth.min = "0"; - borderWidth.max = "100"; - borderWidth.addEventListener("input", () => { - if (borderWidth.value) { - setStyleForSelected("border-style", "solid"); - setStyleForSelected("border-width", `${borderWidth.value}px`); - } - }); - bordersSection.appendChild(createField("Border width", borderWidth)); - - const dimensions = document.createElement("div"); - dimensions.style.cssText = - "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center"; - const dimensionLabel = document.createElement("div"); - dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground"; - dimensionLabel.innerHTML = "WidthHeight"; - const dimensionControls = document.createElement("div"); - dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px"; - const widthInput = createUnitInput("px", "auto"); - const heightInput = createUnitInput("px", "auto"); - styleControl(widthInput); - styleControl(heightInput); - const aspectLock = createButton("", "Lock aspect ratio"); - aspectLock.setAttribute("aria-pressed", "true"); - aspectLock.style.cssText += - ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0"; - aspectLock.className += " bg-primary/10 text-primary"; - dimensionControls.append( - createUnitControl(widthInput), - createUnitControl(heightInput), - aspectLock, - ); - dimensions.append(dimensionLabel, dimensionControls); - sizingSection.appendChild(dimensions); - - let aspectLocked = true; - let aspectRatio = 1; - const refreshAspectButton = (): void => { - aspectLock.innerHTML = aspectLocked - ? '' - : ''; - aspectLock.setAttribute("aria-pressed", String(aspectLocked)); - aspectLock.classList.toggle("bg-primary/10", aspectLocked); - aspectLock.classList.toggle("text-primary", aspectLocked); - aspectLock.classList.toggle("bg-muted", !aspectLocked); - aspectLock.classList.toggle("text-muted-foreground", !aspectLocked); - }; - aspectLock.addEventListener("click", () => { - aspectLocked = !aspectLocked; - refreshAspectButton(); - }); - widthInput.addEventListener("input", () => { - const width = Number(widthInput.value); - if (!Number.isFinite(width) || width <= 0) return; - setStyleForSelected("width", `${width}px`); - if (aspectLocked && aspectRatio > 0) { - const height = Math.max(1, Math.round(width / aspectRatio)); - heightInput.value = String(height); - setStyleForSelected("height", `${height}px`); - } - }); - heightInput.addEventListener("input", () => { - const height = Number(heightInput.value); - if (!Number.isFinite(height) || height <= 0) return; - setStyleForSelected("height", `${height}px`); - if (aspectLocked && aspectRatio > 0) { - const width = Math.max(1, Math.round(height * aspectRatio)); - widthInput.value = String(width); - setStyleForSelected("width", `${width}px`); - } - }); - refreshAspectButton(); - - const addSpacingField = ( - label: string, - property: string, - placeholder: string, - ): HTMLInputElement => { - const input = document.createElement("input"); - input.type = "text"; - input.placeholder = placeholder; - input.addEventListener("change", () => { - if (input.value.trim()) setStyleForSelected(property, input.value.trim()); - }); - sizingSection.appendChild(createField(label, input)); - return input; - }; - const padding = addSpacingField("Padding", "padding", "0 0 0 0"); - const margin = addSpacingField("Margin", "margin", "0 0 0 0"); - const gap = addSpacingField("Gap", "gap", "0px"); - - const syncStyleControls = (): void => { - const first = selected.values().next().value as SelectedElement | undefined; - if (!first) return; - const computed = getComputedStyle(first.element); - const rect = first.element.getBoundingClientRect(); - aspectRatio = rect.height > 0 ? rect.width / rect.height : 1; - widthInput.value = String(Math.round(rect.width)); - heightInput.value = String(Math.round(rect.height)); - fontSize.value = String(Math.round(Number.parseFloat(computed.fontSize) || 16)); - fontWeight.value = computed.fontWeight.match(/^[0-9]+$/) ? computed.fontWeight : "400"; - lineHeight.value = computed.lineHeight; - fontFamily.value = Array.from(fontFamily.options).some( - (option) => option.value === computed.fontFamily, - ) - ? computed.fontFamily - : "inherit"; - textColor.text.value = computed.color; - backgroundColor.text.value = computed.backgroundColor; - borderColor.text.value = computed.borderColor; - opacity.value = computed.opacity; - radius.value = String(Math.round(Number.parseFloat(computed.borderRadius) || 0)); - borderWidth.value = String(Math.round(Number.parseFloat(computed.borderWidth) || 0)); - padding.value = computed.padding; - margin.value = computed.margin; - gap.value = computed.gap === "normal" ? "0px" : computed.gap; - }; - - const tools: ReadonlyArray<[AnnotationTool, string, string]> = [ - ["select", "Select", "Select elements (V)"], - ["marquee", "Region", "Draw a region or marquee-select elements (R)"], - ["draw", "Draw", "Draw freehand (D)"], - ["erase", "Erase", "Remove an annotation target (E)"], - ]; - for (const [candidate, label, title] of tools) { - const button = createButton(label, title); - button.className += " h-8 px-2.5 text-sm"; - button.addEventListener("click", () => { - tool = candidate; - refreshToolButtons(); - }); - toolButtons.set(candidate, button); - toolbar.appendChild(button); - } - - const clampEditorPosition = (left: number, top: number): { left: number; top: number } => { - const margin = 8; - const rect = editor.getBoundingClientRect(); - return { - left: Math.min( - Math.max(margin, left), - Math.max(margin, window.innerWidth - rect.width - margin), - ), - top: Math.min( - Math.max(margin, top), - Math.max(margin, window.innerHeight - rect.height - margin), - ), - }; - }; - - const applyEditorPosition = (position: { left: number; top: number }): void => { - const clamped = clampEditorPosition(position.left, position.top); - editor.style.left = `${clamped.left}px`; - editor.style.top = `${clamped.top}px`; - editor.style.right = "auto"; - editor.style.bottom = "auto"; - if (editorExpanded) editorPosition = clamped; - }; - - const getAnnotationBounds = (): PreviewAnnotationRect | null => - unionRects( - [ - ...Array.from(selected.values(), (target) => - rectFromDomRect(target.element.getBoundingClientRect()), - ), - ...regions.map((region) => region.rect), - ...strokes.map((stroke) => stroke.bounds), - ], - 0, - ); - - const positionCompactEditor = (): void => { - const bounds = getAnnotationBounds(); - if (!bounds) return; - const editorRect = editor.getBoundingClientRect(); - const gap = 8; - const candidates = [ - { left: bounds.x + bounds.width + gap, top: bounds.y }, - { left: bounds.x - editorRect.width - gap, top: bounds.y }, - { - left: bounds.x + bounds.width - editorRect.width, - top: bounds.y + bounds.height + gap, - }, - { - left: bounds.x + bounds.width - editorRect.width, - top: bounds.y - editorRect.height - gap, - }, - ]; - const overflow = (position: { left: number; top: number }): number => - Math.max(0, -position.left) + - Math.max(0, -position.top) + - Math.max(0, position.left + editorRect.width - window.innerWidth) + - Math.max(0, position.top + editorRect.height - window.innerHeight); - const best = candidates.reduce((current, candidate) => - overflow(candidate) < overflow(current) ? candidate : current, - ); - applyEditorPosition(best); - }; - - function queueEditorLayout(): void { - if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); - editorLayoutFrame = window.requestAnimationFrame(() => { - editorLayoutFrame = null; - if (editor.style.display === "none") return; - if (editorExpanded && editorPosition) applyEditorPosition(editorPosition); - else positionCompactEditor(); - }); - } - - adjust.addEventListener("click", () => { - if (selected.size === 0) return; - if (!editorExpanded) { - const rect = editor.getBoundingClientRect(); - editorExpanded = true; - editorPosition = { left: rect.left, top: rect.top }; - stylePanel.style.display = selected.size > 0 ? "grid" : "none"; - dragHandle.style.display = "block"; - adjust.setAttribute("aria-expanded", "true"); - adjust.title = "Collapse annotation editor"; - adjust.setAttribute("aria-label", "Collapse annotation editor"); - if (selected.size > 0) syncStyleControls(); - } else { - editorExpanded = false; - editorPosition = null; - stylePanel.style.display = "none"; - dragHandle.style.display = "none"; - adjust.setAttribute("aria-expanded", "false"); - adjust.title = "Expand annotation editor"; - adjust.setAttribute("aria-label", "Expand annotation editor"); - } - queueEditorLayout(); - }); - - const onEditorPointerDown = (event: PointerEvent): void => { - if (event.button !== 0 || !editorExpanded) return; - const rect = editor.getBoundingClientRect(); - editorDrag = { - pointerId: event.pointerId, - offsetX: event.clientX - rect.left, - offsetY: event.clientY - rect.top, - }; - dragHandle.setPointerCapture(event.pointerId); - dragHandle.style.cursor = "grabbing"; - event.preventDefault(); - event.stopPropagation(); - }; - - const onEditorPointerMove = (event: PointerEvent): void => { - if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; - applyEditorPosition({ - left: event.clientX - editorDrag.offsetX, - top: event.clientY - editorDrag.offsetY, - }); - event.preventDefault(); - event.stopPropagation(); - }; - - const onEditorPointerUp = (event: PointerEvent): void => { - if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; - editorDrag = null; - dragHandle.style.cursor = "grab"; - if (dragHandle.hasPointerCapture(event.pointerId)) - dragHandle.releasePointerCapture(event.pointerId); - event.preventDefault(); - event.stopPropagation(); - }; - dragHandle.addEventListener("pointerdown", onEditorPointerDown); - dragHandle.addEventListener("pointermove", onEditorPointerMove); - dragHandle.addEventListener("pointerup", onEditorPointerUp); - dragHandle.addEventListener("pointercancel", onEditorPointerUp); - - const repaint = (): void => { - for (const target of selected.values()) updateSelectedVisual(target); - queueEditorLayout(); - }; - - const removeTargetAtPoint = (x: number, y: number): boolean => { - for (const target of Array.from(selected.values()).toReversed()) { - const rect = target.element.getBoundingClientRect(); - if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { - removeSelected(target); - return true; - } - } - const regionIndex = regions.findIndex( - (region) => - x >= region.rect.x && - x <= region.rect.x + region.rect.width && - y >= region.rect.y && - y <= region.rect.y + region.rect.height, - ); - if (regionIndex >= 0) { - const [removed] = regions.splice(regionIndex, 1); - root.querySelector(`[data-region-id="${removed?.id}"]`)?.remove(); - updateStatus(); - return true; - } - const strokeIndex = strokes.findIndex( - (stroke) => - x >= stroke.bounds.x && - x <= stroke.bounds.x + stroke.bounds.width && - y >= stroke.bounds.y && - y <= stroke.bounds.y + stroke.bounds.height, - ); - if (strokeIndex >= 0) { - const [removed] = strokes.splice(strokeIndex, 1); - svg.querySelector(`[data-stroke-id="${removed?.id}"]`)?.remove(); - updateStatus(); - return true; - } - return false; - }; - - const selectElementsInRect = (rect: PreviewAnnotationRect): number => { - const candidates = Array.from(document.querySelectorAll("body *")) - .filter((element) => !isAnnotationNode(element)) - .map((element) => ({ element, rect: element.getBoundingClientRect() })) - .filter(({ rect: candidate }) => { - if (candidate.width < 2 || candidate.height < 2) return false; - return !( - candidate.right < rect.x || - candidate.left > rect.x + rect.width || - candidate.bottom < rect.y || - candidate.top > rect.y + rect.height - ); - }) - .filter(({ element, rect: candidate }) => { - const centerX = candidate.left + candidate.width / 2; - const centerY = candidate.top + candidate.height / 2; - return ( - centerX >= rect.x && - centerX <= rect.x + rect.width && - centerY >= rect.y && - centerY <= rect.y + rect.height && - (element.children.length === 0 || - element instanceof HTMLButtonElement || - element instanceof HTMLAnchorElement || - element.getAttribute("role") === "button") - ); - }) - .sort( - (left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height, - ) - .slice(0, MAX_MARQUEE_ELEMENTS); - for (const candidate of candidates) addSelected(candidate.element); - return candidates.length; - }; - - const clearHoverOutline = (): void => { - hoverOutline.style.display = "none"; - }; - - const onPointerMove = (event: PointerEvent): void => { - if (isAnnotationNode(event.target as Element)) { - clearHoverOutline(); - return; - } - if (tool === "select" && dragStart === null) { - const target = pickFromPoint(event.clientX, event.clientY); - if (target) positionBox(hoverOutline, rectFromDomRect(target.getBoundingClientRect())); - else clearHoverOutline(); - return; - } - clearHoverOutline(); - if (tool === "marquee" && dragStart) { - positionBox( - marqueeBox, - normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY), - ); - return; - } - if (tool === "draw" && activeStroke) { - activeStroke.target.points = [ - ...activeStroke.target.points, - { x: event.clientX, y: event.clientY }, - ]; - activeStroke.target.bounds = strokeBounds( - activeStroke.target.points, - activeStroke.target.width, - ); - activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points)); - } - }; - - const onPointerDown = (event: PointerEvent): void => { - if (event.button !== 0 || isAnnotationNode(event.target as Element)) return; - event.preventDefault(); - event.stopPropagation(); - if (tool === "select") { - const target = pickFromPoint(event.clientX, event.clientY); - if (target) toggleSelected(target, event.shiftKey); - return; - } - if (tool === "erase") { - removeTargetAtPoint(event.clientX, event.clientY); - return; - } - dragStart = { x: event.clientX, y: event.clientY }; - if (tool === "draw") { - const stroke: PreviewAnnotationStrokeTarget = { - id: nextId("stroke"), - color: annotationTheme?.primary ?? "#2563eb", - width: 4, - points: [dragStart], - bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, - }; - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute(OVERLAY_ATTRIBUTE, ""); - path.setAttribute("data-stroke-id", stroke.id); - path.setAttribute("fill", "none"); - path.setAttribute("stroke", stroke.color); - path.setAttribute("stroke-width", String(stroke.width)); - path.setAttribute("stroke-linecap", "round"); - path.setAttribute("stroke-linejoin", "round"); - svg.appendChild(path); - activeStroke = { target: stroke, path }; - } - }; - - const onPointerUp = (event: PointerEvent): void => { - if (!dragStart) return; - event.preventDefault(); - event.stopPropagation(); - if (tool === "marquee") { - const rect = normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY); - marqueeBox.style.display = "none"; - if (isUsableRect(rect)) { - const found = selectElementsInRect(rect); - if (found === 0) { - const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; - regions.push(region); - const regionBox = createBox( - PRIMARY, - "color-mix(in srgb, var(--t3-primary) 6%, transparent)", - ); - regionBox.setAttribute("data-region-id", region.id); - positionBox(regionBox, rect); - root.appendChild(regionBox); - } - } - } else if (tool === "draw" && activeStroke) { - if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target); - else activeStroke.path.remove(); - activeStroke = null; - } - dragStart = null; - updateStatus(); - }; - - const onClick = (event: MouseEvent): void => { - if (isAnnotationNode(event.target as Element)) return; - event.preventDefault(); - event.stopPropagation(); - }; - - const onPointerOut = (event: PointerEvent): void => { - if (event.relatedTarget === null) clearHoverOutline(); - }; - - const onWindowBlur = (): void => { - clearHoverOutline(); - }; - - const restoreStyles = (): void => { - for (const target of selected.values()) { - if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) - continue; - for (const [property, baseline] of target.baselineStyles) { - if (baseline) target.element.style.setProperty(property, baseline); - else target.element.style.removeProperty(property); - } - } - }; - - const teardown = (notifyMain: boolean): void => { - if (finished) return; - finished = true; - restoreStyles(); - window.removeEventListener("pointermove", onPointerMove, true); - window.removeEventListener("pointerdown", onPointerDown, true); - window.removeEventListener("pointerup", onPointerUp, true); - window.removeEventListener("pointerout", onPointerOut, true); - window.removeEventListener("click", onClick, true); - window.removeEventListener("blur", onWindowBlur); - window.removeEventListener("keydown", onKeyDown, true); - window.removeEventListener("scroll", repaint, true); - window.removeEventListener("resize", repaint); - dragHandle.removeEventListener("pointerdown", onEditorPointerDown); - dragHandle.removeEventListener("pointermove", onEditorPointerMove); - dragHandle.removeEventListener("pointerup", onEditorPointerUp); - dragHandle.removeEventListener("pointercancel", onEditorPointerUp); - if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); - ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); - ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); - document.documentElement.removeAttribute("data-t3code-annotation-tool"); - cursorStyle.remove(); - host.remove(); - activeSession = null; - if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); - }; - - const onCancel = (): void => teardown(false); - const onCaptured = (): void => teardown(false); - const onKeyDown = (event: KeyboardEvent): void => { - if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return; - if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - teardown(true); - return; - } - if (event.key === "v") tool = "select"; - else if (event.key === "r") tool = "marquee"; - else if (event.key === "d") tool = "draw"; - else if (event.key === "e") tool = "erase"; - else return; - refreshToolButtons(); - }; - - submit.addEventListener("click", () => { - if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) - return; - pendingCapture = true; - submit.disabled = true; - submit.textContent = "Capturing…"; - void Promise.all( - Array.from(selected.values()).map(async (target) => { - const element = await captureElement(target.element); - if (!element) return null; - for (const change of styleChanges.values()) { - if (change.targetId === target.id) change.selector = element.selector; - } - return { - id: target.id, - element, - rect: rectFromDomRect(target.element.getBoundingClientRect()), - }; - }), - ).then((captured) => { - const elements = captured.filter((target) => target !== null); - const annotation: PreviewAnnotationPayload = { - id: nextId("annotation"), - pageUrl: location.href, - pageTitle: document.title?.trim() || null, - comment: comment.value.trim(), - elements, - regions: [...regions], - strokes: [...strokes], - styleChanges: Array.from(styleChanges.values()), - screenshot: null, - createdAt: new Date().toISOString(), - }; - editor.style.display = "none"; - toolbar.style.display = "none"; - hoverOutline.style.display = "none"; - const screenshotRect = unionRects([ - ...elements.map((target) => target.rect), - ...regions.map((region) => region.rect), - ...strokes.map((stroke) => stroke.bounds), - ]); - ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect); - }); - }); - comment.addEventListener("keydown", (event) => { - if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return; - event.preventDefault(); - submit.click(); - }); - - window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); - window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false }); - window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false }); - window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true }); - window.addEventListener("click", onClick, { capture: true, passive: false }); - window.addEventListener("blur", onWindowBlur); - window.addEventListener("keydown", onKeyDown, { capture: true }); - window.addEventListener("scroll", repaint, { capture: true, passive: true }); - window.addEventListener("resize", repaint, { passive: true }); - ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); - ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); - document.documentElement.appendChild(host); - refreshToolButtons(); - updateStatus(); - activeSession = { - teardown, - applyTheme: (theme) => applyAnnotationTheme(host, theme), - }; -} - -ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => { - if (theme) annotationTheme = theme; - startAnnotation(); -}); -ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { - annotationTheme = theme; - activeSession?.applyTheme(theme); -}); -ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); +import "./preview/PickPreload.ts"; diff --git a/apps/desktop/src/preview-view-manager.test.ts b/apps/desktop/src/preview-view-manager.test.ts deleted file mode 100644 index a7aeabc0e0d6b63bcb31bd6ac7fe4d845ae78b16..0000000000000000000000000000000000000000 --- a/apps/desktop/src/preview-view-manager.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const fromId = vi.fn(() => null); -const mkdir = vi.fn(async () => undefined); -const writeFile = vi.fn(async () => undefined); -const showItemInFolder = vi.fn(); -const writeImage = vi.fn(); -const createFromPath = vi.fn(() => ({ isEmpty: () => false })); -const webviewSend = vi.fn(); - -vi.mock("node:fs/promises", () => ({ mkdir, writeFile })); - -vi.mock("electron", () => ({ - clipboard: { - writeImage, - }, - nativeImage: { - createFromPath, - }, - session: { - fromPartition: vi.fn(), - }, - shell: { - showItemInFolder, - }, - webContents: { - fromId, - }, -})); - -describe("PreviewViewManager automation status", () => { - beforeEach(() => { - fromId.mockClear(); - mkdir.mockClear(); - writeFile.mockClear(); - showItemInFolder.mockClear(); - writeImage.mockClear(); - createFromPath.mockClear(); - webviewSend.mockClear(); - }); - - it("reports an unregistered webview as temporarily unavailable", async () => { - const { PreviewViewManager } = await import("./preview-view-manager.ts"); - const manager = new PreviewViewManager(); - - expect(manager.automationStatus("tab_1")).toEqual({ - available: false, - visible: true, - tabId: "tab_1", - url: null, - title: null, - loading: false, - }); - - manager.createTab("tab_1"); - - expect(manager.automationStatus("tab_1")).toEqual({ - available: false, - visible: true, - tabId: "tab_1", - url: null, - title: null, - loading: false, - }); - expect(fromId).not.toHaveBeenCalled(); - }); - - it("captures a PNG screenshot into browser artifacts", async () => { - const png = Buffer.from("preview-png"); - const capturePage = vi.fn(async () => ({ toPNG: () => png })); - const listeners = new Map void>(); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com:8443/path?query=value", - getTitle: () => "Example", - isLoading: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - on: vi.fn((event: string, listener: (...args: never[]) => void) => { - listeners.set(event, listener); - }), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - capturePage, - } as never); - const { PreviewViewManager } = await import("./preview-view-manager.ts"); - const manager = new PreviewViewManager(); - manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); - manager.createTab("tab_1"); - manager.registerWebview("tab_1", 42); - - expect(webviewSend).toHaveBeenCalledWith( - "preview:annotation-theme", - expect.objectContaining({ - colorScheme: "light", - primary: "oklch(0.488 0.217 264)", - }), - ); - - const artifact = await manager.captureScreenshot("tab_1"); - - expect(capturePage).toHaveBeenCalledOnce(); - expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts", { - recursive: true, - }); - expect(writeFile).toHaveBeenCalledWith(artifact.path, png); - expect(artifact).toMatchObject({ - tabId: "tab_1", - mimeType: "image/png", - sizeBytes: png.byteLength, - }); - expect(artifact.path).toMatch( - /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, - ); - }); - - it("reveals only files inside the configured browser artifact directory", async () => { - const { PreviewViewManager } = await import("./preview-view-manager.ts"); - const manager = new PreviewViewManager(); - manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); - - manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"); - - expect(showItemInFolder).toHaveBeenCalledWith( - "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png", - ); - expect(() => manager.revealArtifact("/tmp/t3/dev/settings.json")).toThrow( - "outside the configured artifact directory", - ); - }); - - it("copies screenshot artifacts to the system clipboard", async () => { - const { PreviewViewManager } = await import("./preview-view-manager.ts"); - const manager = new PreviewViewManager(); - manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); - const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"; - - manager.copyArtifactToClipboard(artifactPath); - - expect(createFromPath).toHaveBeenCalledWith(artifactPath); - expect(writeImage).toHaveBeenCalledOnce(); - expect(() => manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json")).toThrow( - "outside the configured artifact directory", - ); - }); - - it("emits the resolved pointer target before dispatching an automation click", async () => { - let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; - const activity: string[] = []; - const sendCommand = vi.fn(async (method: string, params?: Record) => { - if (method === "Runtime.evaluate") { - return { - result: { - value: { width: 800, height: 600 }, - }, - }; - } - if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { - activity.push("mousePressed"); - humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); - } - return undefined; - }); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoading: () => false, - isDevToolsOpened: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - on: vi.fn(), - off: vi.fn(), - ipc: { - on: vi.fn((channel: string, listener: typeof humanInput) => { - if (channel === "preview:human-input") humanInput = listener; - }), - off: vi.fn(), - }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand, - on: vi.fn(), - off: vi.fn(), - }, - } as never); - const { PreviewViewManager } = await import("./preview-view-manager.ts"); - const manager = new PreviewViewManager(); - manager.onPointerEvent((event) => activity.push(event.phase)); - manager.createTab("tab_1"); - manager.registerWebview("tab_1", 42); - - await manager.automationClick("tab_1", { x: 120, y: 80 }); - - expect(activity).toEqual(["move", "click", "mousePressed"]); - expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { - type: "mousePressed", - x: 120, - y: 80, - button: "left", - clickCount: 1, - }); - expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { - type: "mouseReleased", - x: 120, - y: 80, - button: "left", - clickCount: 1, - }); - }); - - it("still interrupts agent control for a different human pointer event", async () => { - let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; - const sendCommand = vi.fn(async (method: string) => { - if (method === "Runtime.evaluate") { - return { - result: { - value: { width: 800, height: 600 }, - }, - }; - } - if (method === "Input.dispatchMouseEvent") { - humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 }); - } - return undefined; - }); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoading: () => false, - isDevToolsOpened: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - on: vi.fn(), - off: vi.fn(), - ipc: { - on: vi.fn((channel: string, listener: typeof humanInput) => { - if (channel === "preview:human-input") humanInput = listener; - }), - off: vi.fn(), - }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand, - on: vi.fn(), - off: vi.fn(), - }, - } as never); - const { PreviewViewManager } = await import("./preview-view-manager.ts"); - const manager = new PreviewViewManager(); - manager.createTab("tab_1"); - manager.registerWebview("tab_1", 42); - - await expect(manager.automationClick("tab_1", { x: 120, y: 80 })).rejects.toMatchObject({ - name: "PreviewAutomationControlInterruptedError", - }); - }); -}); diff --git a/apps/desktop/src/preview-view-manager.ts b/apps/desktop/src/preview-view-manager.ts deleted file mode 100644 index 5406ae731b05425211fd977181ab76cfa76083d1..0000000000000000000000000000000000000000 --- a/apps/desktop/src/preview-view-manager.ts +++ /dev/null @@ -1,1741 +0,0 @@ -// @effect-diagnostics globalDate:off -// @effect-diagnostics nodeBuiltinImport:off -/** - * PreviewViewManager — desktop side of the in-app browser preview. - * - * Hosts per-tab Chromium WebContents references (the actual - * elements live in the renderer; we only attach listeners and forward state - * here). Single layer-scoped browser session partition. - */ -import type { - DesktopPreviewAnnotationTheme, - DesktopPreviewPointerEvent, - PreviewAnnotationPayload, - PreviewAnnotationRect, - DesktopPreviewRecordingArtifact, - DesktopPreviewRecordingFrame, - DesktopPreviewScreenshotArtifact, - PreviewAutomationClickInput, - PreviewAutomationActionEvent, - PreviewAutomationConsoleEntry, - PreviewAutomationEvaluateInput, - PreviewAutomationPressInput, - PreviewAutomationNetworkEntry, - PreviewAutomationScrollInput, - PreviewAutomationSnapshot, - PreviewAutomationStatus, - PreviewAutomationTypeInput, - PreviewAutomationWaitForInput, -} from "@t3tools/contracts"; -import { normalizePreviewUrl } from "@t3tools/shared/preview"; -import { - type BrowserWindow, - type Session, - clipboard, - nativeImage, - session, - shell, - webContents, -} from "electron"; -import { mkdir, writeFile } from "node:fs/promises"; -import { isAbsolute, join, relative, resolve, sep } from "node:path"; -import { createHash } from "node:crypto"; -import { setTimeout as sleep } from "node:timers/promises"; - -import { isPreviewAnnotationPayload } from "./picked-element-payload.ts"; -import { playwrightInjectedRuntimeInstallExpression } from "./playwright-injected-runtime.ts"; - -const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; -const START_PICK_CHANNEL = "preview:start-pick"; -const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; -const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; -const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; -const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; -const HUMAN_INPUT_CHANNEL = "preview:human-input"; - -// Re-export the guest webview security posture from its dedicated module so -// the constant is unit-testable in isolation. See -// `preview-webview-preferences.ts` for the full security rationale. -export { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; -import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.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; - controller: "human" | "agent" | "none"; - updatedAt: string; -} - -/** Discrete zoom levels mirroring Chrome's preset list. */ -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 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 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 }; - }; -} - -const automationError = ( - tag: - | "PreviewAutomationExecutionError" - | "PreviewAutomationInvalidSelectorError" - | "PreviewAutomationResultTooLargeError" - | "PreviewAutomationTimeoutError" - | "PreviewAutomationControlInterruptedError", - message: string, - detail?: unknown, -): Error & { detail?: unknown } => { - const error = new Error(message) as Error & { detail?: unknown }; - error.name = tag; - if (detail !== undefined) error.detail = detail; - return error; -}; - -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: 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 = async ( - wc: Electron.WebContents, - cropRect: PreviewAnnotationRect | null, -) => { - const image = await wc.capturePage( - cropRect - ? { - x: cropRect.x, - y: cropRect.y, - width: cropRect.width, - height: cropRect.height, - } - : undefined, - ); - 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 => { - for (let index = 0; index < ZOOM_LEVELS.length; index += 1) { - if (Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON) return index; - if (ZOOM_LEVELS[index]! > current) return index - 1; - } - return ZOOM_LEVELS.length - 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) => void; -type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => 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 { - navigate: () => void; - failed: (event: Event, code: number, description: string) => void; - humanInput: (_event: unknown, signal?: unknown) => void; -} - -interface PickSession { - readonly resolve: (payload: PreviewAnnotationPayload | null) => void; - readonly cleanup: () => void; -} - -interface BrowserControlSession { - readonly webContentsId: number; - tail: Promise; - initialized: Promise; -} - -interface BrowserDiagnostics { - readonly consoleEntries: PreviewAutomationConsoleEntry[]; - readonly networkEntries: PreviewAutomationNetworkEntry[]; - readonly requests: Map; -} - -type PointerEventListener = (event: DesktopPreviewPointerEvent) => 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 - ); -}; - -export class PreviewViewManager { - private annotationTheme = DEFAULT_ANNOTATION_THEME; - private artifactDirectory: string | null = null; - private mainWindow: BrowserWindow | null = null; - private readonly tabs = new Map(); - private readonly attached = new Map(); - private readonly browserSessions = new Map(); - private readonly listeners = new Set(); - private readonly pointerEventListeners = new Set(); - private readonly recordingFrameListeners = new Set(); - /** In-flight preview annotation sessions, keyed by tabId. */ - private readonly pickSessions = new Map(); - /** One long-lived CDP attachment and serialized command queue per guest. */ - private readonly controlSessions = new Map(); - private readonly diagnostics = new Map(); - private readonly expectedAgentInputs = new Map(); - private readonly controlEpoch = new Map(); - private readonly actionTimeline = new Map(); - private actionSequence = 0; - private pointerSequence = 0; - private recordingTabId: string | null = null; - - configureArtifactDirectory(directory: string): void { - this.artifactDirectory = resolve(directory); - } - - private requireArtifactDirectory(): string { - if (!this.artifactDirectory) { - throw new Error("Preview artifact directory is not configured."); - } - return this.artifactDirectory; - } - - private resolveArtifactPath(path: string): string { - const directory = this.requireArtifactDirectory(); - const resolvedPath = resolve(path); - const relativePath = relative(directory, resolvedPath); - if ( - relativePath.length === 0 || - relativePath === ".." || - relativePath.startsWith(`..${sep}`) || - isAbsolute(relativePath) - ) { - throw new Error("Preview artifact path is outside the configured artifact directory."); - } - return resolvedPath; - } - - revealArtifact(path: string): void { - const resolvedPath = this.resolveArtifactPath(path); - shell.showItemInFolder(resolvedPath); - } - - copyArtifactToClipboard(path: string): void { - const resolvedPath = this.resolveArtifactPath(path); - const image = nativeImage.createFromPath(resolvedPath); - if (image.isEmpty()) { - throw new Error("Preview artifact could not be loaded as an image."); - } - clipboard.writeImage(image); - } - - setAnnotationTheme(theme: DesktopPreviewAnnotationTheme): void { - this.annotationTheme = theme; - for (const tab of this.tabs.values()) { - if (tab.webContentsId == null) continue; - const wc = webContents.fromId(tab.webContentsId); - if (!wc || wc.isDestroyed()) continue; - wc.send(ANNOTATION_THEME_CHANNEL, theme); - } - } - - setMainWindow(window: BrowserWindow): void { - this.mainWindow = window; - } - - getBrowserPartition(scope = "shared"): string { - const digest = createHash("sha256").update(scope).digest("hex").slice(0, 20); - return `${PREVIEW_PARTITION_PREFIX}${digest}`; - } - - isBrowserPartition(partition: string): boolean { - return partition.startsWith(PREVIEW_PARTITION_PREFIX); - } - - /** - * Returns the canonical `` string. Renderer - * fetches this via the desktop bridge so the security posture for guest - * surfaces lives in exactly one place (here) and any future guest webview - * (docs panel, OAuth popup, etc.) can opt in by calling the same getter. - */ - getWebviewPreferences(): string { - return PREVIEW_WEBVIEW_PREFERENCES; - } - - getBrowserSession(scope = "shared"): Session { - const partition = this.getBrowserPartition(scope); - const existing = this.browserSessions.get(partition); - if (existing) return existing; - const sess = session.fromPartition(partition); - const ua = sess - .getUserAgent() - .replace(/Electron\/[\d.]+ /, "") - .replace(/\s*t3code\/[\d.]+/, ""); - sess.setUserAgent(ua); - sess.setPermissionRequestHandler((_wc, perm, callback) => { - const allow = ["clipboard-read", "clipboard-write", "notifications", "geolocation"]; - callback(allow.includes(perm)); - }); - this.browserSessions.set(partition, sess); - return sess; - } - - createTab(tabId: string): PreviewTabState { - const existing = this.tabs.get(tabId); - if (existing) return existing; - const initial: PreviewTabState = { - tabId, - webContentsId: null, - navStatus: { kind: "Idle" }, - canGoBack: false, - canGoForward: false, - zoomFactor: DEFAULT_ZOOM_FACTOR, - controller: "none", - updatedAt: new Date().toISOString(), - }; - this.tabs.set(tabId, initial); - this.emit(tabId, initial); - return initial; - } - - closeTab(tabId: string): void { - const tab = this.tabs.get(tabId); - if (!tab) return; - this.cancelPickElement(tabId); - if (tab.webContentsId != null) { - this.detachControlSession(tab.webContentsId); - this.detachListeners(tab.webContentsId); - } - const closed: PreviewTabState = { - ...tab, - webContentsId: null, - navStatus: { kind: "Idle" }, - canGoBack: false, - canGoForward: false, - zoomFactor: DEFAULT_ZOOM_FACTOR, - controller: "none", - updatedAt: new Date().toISOString(), - }; - this.tabs.delete(tabId); - this.emit(tabId, closed); - } - - registerWebview(tabId: string, webContentsId: number): void { - const tab = this.tabs.get(tabId); - if (!tab) { - throw new PreviewTabNotFoundError(tabId); - } - const wc = webContents.fromId(webContentsId); - if (!wc) { - throw new PreviewWebContentsNotFoundError(tabId, webContentsId); - } - // Defence in depth: a malicious renderer could otherwise trick us into - // attaching listeners to the main window's WebContents (or any other - // process) by passing an arbitrary id. - if (wc.getType() !== "webview") { - throw new PreviewWebContentsNotFoundError(tabId, webContentsId); - } - if (this.mainWindow && wc.hostWebContents !== this.mainWindow.webContents) { - throw new PreviewWebContentsNotFoundError(tabId, webContentsId); - } - if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) { - wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); - return; - } - if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { - this.detachControlSession(tab.webContentsId); - this.detachListeners(tab.webContentsId); - // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`. - // Cancel it so the toggle button doesn't get stuck pressed waiting - // forever for a click on a webview that no longer hosts the listener. - this.cancelPickElement(tabId); - } - this.attachListeners(tabId, wc); - void this.ensureControlSession(wc).catch(() => undefined); - // Restore the persisted zoom factor onto the freshly-attached WebContents - // so a thread-switch + remount lands the user back where they were. - if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { - try { - wc.setZoomFactor(tab.zoomFactor); - } catch { - // wc may have been torn down between resolution and call. - } - } - this.update(tabId, { - webContentsId, - navStatus: this.computeNavStatus(wc), - canGoBack: wc.navigationHistory.canGoBack(), - canGoForward: wc.navigationHistory.canGoForward(), - zoomFactor: tab.zoomFactor, - }); - wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); - } - - async navigate(tabId: string, rawUrl: string): Promise { - const wc = this.requireWebContents(tabId); - const url = this.normalizeUrl(rawUrl); - if (wc.getURL() === url) { - wc.reload(); - return; - } - await wc.loadURL(url); - } - - goBack(tabId: string): void { - const wc = this.requireWebContents(tabId); - if (wc.navigationHistory.canGoBack()) { - wc.navigationHistory.goBack(); - } - } - - goForward(tabId: string): void { - const wc = this.requireWebContents(tabId); - if (wc.navigationHistory.canGoForward()) { - wc.navigationHistory.goForward(); - } - } - - refresh(tabId: string): void { - const wc = this.requireWebContents(tabId); - wc.reload(); - } - - /** Bypass HTTP cache on the next load — equivalent to Cmd+Shift+R. */ - hardReload(tabId: string): void { - const wc = this.requireWebContents(tabId); - wc.reloadIgnoringCache(); - } - - /** - * Open the guest webview's DevTools, detached so it doesn't steal panel - * area. Idempotent — re-invoking focuses the existing window. - */ - openDevTools(tabId: string): void { - const wc = this.requireWebContents(tabId); - if (wc.isDevToolsOpened()) { - wc.devToolsWebContents?.focus(); - return; - } - this.detachControlSession(wc.id); - wc.once("devtools-closed", () => { - if (!wc.isDestroyed()) void this.ensureControlSession(wc).catch(() => undefined); - }); - wc.openDevTools({ mode: "detach" }); - } - - /** - * Drop cookies/localStorage/etc. for the preview partition. Affects every - * preview tab since they all share `persist:t3code-preview`. - */ - async clearCookies(): Promise { - await Promise.all( - [...this.browserSessions.values()].map((sess) => - sess.clearStorageData({ - storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], - }), - ), - ); - } - - /** Drop the HTTP cache for the preview partition. */ - async clearCache(): Promise { - await Promise.all([...this.browserSessions.values()].map((sess) => sess.clearCache())); - } - - /** - * Activate annotation mode for `tabId`. Resolves after the guest submits a - * multi-target annotation and the desktop process captures its screenshot, - * or with `null` when the user cancels. - * - * Exactly one pick session may be active per tab — re-invoking while a - * pick is in flight cleanly resolves the old session with `null` first. - */ - async pickElement(tabId: string): Promise { - const wc = this.requireWebContents(tabId); - this.cancelPickElement(tabId); - return new Promise((resolve) => { - // `wc.ipc` is the per-WebContents IpcMain that receives messages the - // webview's preload sends with `ipcRenderer.send(...)`. We use that - // (not the global `wc.on("ipc-message", ...)`, which is for - // `sendToHost` and only fires on the host renderer's - // element) so the main process actually observes the picked payload. - const cleanup = () => { - wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); - wc.off("destroyed", onDestroyed); - wc.off("did-start-navigation", onNavigated); - this.pickSessions.delete(tabId); - }; - const session: PickSession = { resolve, cleanup }; - const settle = (payload: PreviewAnnotationPayload | null) => { - if (this.pickSessions.get(tabId) !== session) return; - cleanup(); - resolve(payload); - }; - const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { - const payload = args[0]; - if (payload == null) { - settle(null); - return; - } - if (!isPreviewAnnotationPayload(payload)) { - settle(null); - return; - } - const cropRect = normalizeCaptureRect(args[1]); - void captureAnnotationScreenshot(wc, cropRect) - .then((screenshot) => settle({ ...payload, screenshot })) - .catch(() => settle(payload)) - .finally(() => { - if (wc.isDestroyed()) return; - try { - wc.send(ANNOTATION_CAPTURED_CHANNEL); - } catch { - // The guest may have navigated while capture was in flight. - } - }); - }; - const onDestroyed = () => settle(null); - const onNavigated = () => settle(null); - wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); - wc.once("destroyed", onDestroyed); - // A page navigation (incl. SPA → same-document) tears down the - // preload's listeners, so we cancel proactively to avoid hanging. - wc.once("did-start-navigation", onNavigated); - this.pickSessions.set(tabId, session); - // Force-focus the guest webContents BEFORE sending start-pick. Without - // this, Electron's input router will deliver the user's first - // mousemove/click to the host renderer (where the pick button lives) - // instead of to the guest's window listeners — manifest as "the - // picker overlay never appears on remote pages I haven't clicked - // into yet". The renderer-side handler in `PreviewView` is responsible - // for restoring focus to the previously-active host element when the - // pick promise resolves so the user's textarea cursor isn't lost. - try { - if (!wc.isFocused()) wc.focus(); - } catch { - // wc may be torn down; the next try/catch settles. - } - try { - wc.send(START_PICK_CHANNEL, this.annotationTheme); - } catch { - settle(null); - } - }); - } - - cancelPickElement(tabId: string): void { - const session = this.pickSessions.get(tabId); - if (!session) return; - session.cleanup(); - // Best-effort: tell the page to dismiss the overlay even if it's still - // alive — keeps the next invoke fresh. - const tab = this.tabs.get(tabId); - if (tab?.webContentsId != null) { - const wc = webContents.fromId(tab.webContentsId); - if (wc && !wc.isDestroyed()) { - try { - wc.send(CANCEL_PICK_CHANNEL); - } catch { - // wc may have been torn down; nothing to clean up. - } - } - } - session.resolve(null); - } - - async startRecording(tabId: string): Promise { - if (this.recordingTabId && this.recordingTabId !== tabId) { - throw new Error("Only one browser recording can be active per window."); - } - const wc = this.requireWebContents(tabId); - await this.withControlSession(tabId, wc, "recording.start", async (send) => { - await send("Page.enable"); - await send("Page.startScreencast", { - format: "jpeg", - quality: 80, - maxWidth: 1600, - maxHeight: 1200, - everyNthFrame: 1, - }); - }); - this.recordingTabId = tabId; - } - - async captureScreenshot(tabId: string): Promise { - const wc = this.requireWebContents(tabId); - const createdAt = new Date().toISOString(); - const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${Date.now().toString(36)}`; - const directory = this.requireArtifactDirectory(); - const path = join(directory, `${id}.png`); - const data = (await wc.capturePage()).toPNG(); - await mkdir(directory, { recursive: true }); - await writeFile(path, data); - return { id, tabId, path, mimeType: "image/png", sizeBytes: data.byteLength, createdAt }; - } - - async stopRecording(tabId: string): Promise { - if (this.recordingTabId !== tabId) return; - const wc = this.requireWebContents(tabId); - await this.withControlSession(tabId, wc, "recording.stop", async (send) => { - await send("Page.stopScreencast"); - }); - this.recordingTabId = null; - } - - async saveRecording( - tabId: string, - mimeType: string, - data: Uint8Array, - ): Promise { - const createdAt = new Date().toISOString(); - const id = `browser-recording-${Date.now().toString(36)}`; - const extension = mimeType.includes("mp4") ? "mp4" : "webm"; - const directory = this.requireArtifactDirectory(); - const path = join(directory, `${id}.${extension}`); - await mkdir(directory, { recursive: true }); - await writeFile(path, data); - return { id, tabId, path, mimeType, sizeBytes: data.byteLength, createdAt }; - } - - onRecordingFrame(listener: RecordingFrameListener): () => void { - this.recordingFrameListeners.add(listener); - return () => this.recordingFrameListeners.delete(listener); - } - - zoomIn(tabId: string): void { - this.applyZoom(tabId, (current) => nextZoomLevel(current, "in")); - } - - zoomOut(tabId: string): void { - this.applyZoom(tabId, (current) => nextZoomLevel(current, "out")); - } - - resetZoom(tabId: string): void { - this.applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR); - } - - automationStatus(tabId: string): PreviewAutomationStatus { - const tab = this.tabs.get(tabId); - if (!tab || tab.webContentsId == null) { - const navStatus = tab?.navStatus; - return { - available: false, - visible: true, - tabId, - url: !navStatus || navStatus.kind === "Idle" ? null : navStatus.url, - title: !navStatus || navStatus.kind === "Idle" ? null : navStatus.title, - loading: navStatus?.kind === "Loading", - }; - } - const wc = webContents.fromId(tab.webContentsId); - if (!wc || wc.isDestroyed()) { - return { - available: false, - visible: true, - tabId, - url: null, - title: null, - loading: false, - }; - } - return { - available: true, - visible: true, - tabId, - url: wc.getURL() || null, - title: wc.getTitle() || null, - loading: wc.isLoading(), - }; - } - - async automationSnapshot(tabId: string): Promise { - const wc = this.requireWebContents(tabId); - return this.withControlSession(tabId, wc, "snapshot", async (send) => { - await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]); - const page = await this.evaluateWithDebugger<{ - url: string; - title: string; - loading: boolean; - visibleText: string; - interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; - }>( - send, - `(() => { - const selectorFor = (element) => { - if (element.id) return "#" + CSS.escape(element.id); - for (const attribute of ["data-testid", "name"]) { - const value = element.getAttribute(attribute); - if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; - } - const parts = []; - let current = element; - while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 8) { - let part = current.tagName.toLowerCase(); - const parent = current.parentElement; - if (parent) { - const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName); - if (siblings.length > 1) part += ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")"; - } - parts.unshift(part); - current = parent; - } - return parts.join(" > "); - }; - const visible = (element) => { - const style = getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; - }; - const elements = Array.from(document.querySelectorAll( - "a[href],button,input,textarea,select,[role],[tabindex]" - )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { - const rect = element.getBoundingClientRect(); - return { - tag: element.tagName.toLowerCase(), - role: element.getAttribute("role"), - name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", - selector: selectorFor(element), - x: rect.x, - y: rect.y, - width: rect.width, - height: rect.height - }; - }); - return { - url: location.href, - title: document.title, - loading: document.readyState !== "complete", - visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), - interactiveElements: elements - }; - })()`, - true, - ); - const accessibility = await send("Accessibility.getFullAXTree"); - let image = await wc.capturePage(); - let size = image.getSize(); - if (size.width > MAX_SCREENSHOT_WIDTH) { - image = image.resize({ width: MAX_SCREENSHOT_WIDTH }); - size = image.getSize(); - } - return { - ...page, - accessibilityTree: accessibility, - consoleEntries: [...(this.diagnostics.get(wc.id)?.consoleEntries ?? [])], - networkEntries: [...(this.diagnostics.get(wc.id)?.networkEntries ?? [])], - actionTimeline: [...(this.actionTimeline.get(tabId) ?? [])], - screenshot: { - mimeType: "image/png", - data: image.toPNG().toString("base64"), - width: size.width, - height: size.height, - }, - }; - }); - } - - async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise { - const wc = this.requireWebContents(tabId); - await this.withControlSession(tabId, wc, "click", async (send) => { - await Promise.all([ - send("Runtime.enable"), - send("Input.setIgnoreInputEvents", { ignore: false }), - ]); - let x: number; - let y: number; - if ("selector" in input || "locator" in input) { - await this.ensurePlaywrightInjected(send); - const locator = this.automationLocator(input); - const point = await this.evaluateWithDebugger< - { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } - >( - send, - `(() => { - try { - const injected = globalThis.__t3PlaywrightInjected; - const parsed = injected.parseSelector(${JSON.stringify(locator)}); - const element = injected.querySelector(parsed, document, true); - if (!element) return { notFound: true }; - const visible = injected.elementState(element, "visible"); - const enabled = injected.elementState(element, "enabled"); - if (!visible.matches || !enabled.matches) return { notFound: true }; - element.scrollIntoView({ block: "center", inline: "center" }); - const rect = element.getBoundingClientRect(); - return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; - } catch (error) { - return { invalidSelector: true, message: String(error) }; - } - })()`, - true, - ); - if ("invalidSelector" in point) { - throw automationError("PreviewAutomationInvalidSelectorError", point.message, { - selector: locator, - }); - } - if ("notFound" in point) { - throw automationError( - "PreviewAutomationExecutionError", - `No element matches locator ${locator}.`, - ); - } - x = point.x; - y = point.y; - } else { - x = input.x!; - y = input.y!; - } - const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>( - send, - "({ width: window.innerWidth, height: window.innerHeight })", - true, - ); - if (x < 0 || y < 0 || x > viewport.width || y > viewport.height) { - throw automationError( - "PreviewAutomationExecutionError", - `Click coordinates (${x}, ${y}) are outside the preview viewport.`, - ); - } - this.emitPointerEvent({ - tabId, - phase: "move", - x, - y, - sequence: this.pointerSequence++, - createdAt: new Date().toISOString(), - }); - await sleep(AGENT_CURSOR_MOVE_MS); - this.emitPointerEvent({ - tabId, - phase: "click", - x, - y, - sequence: this.pointerSequence++, - createdAt: new Date().toISOString(), - }); - await sleep(AGENT_CURSOR_CLICK_LEAD_MS); - this.expectAgentInput(tabId, { kind: "pointer", x, y, button: 0 }); - await send("Input.dispatchMouseEvent", { - type: "mousePressed", - x, - y, - button: "left", - clickCount: 1, - }); - await send("Input.dispatchMouseEvent", { - type: "mouseReleased", - x, - y, - button: "left", - clickCount: 1, - }); - }); - } - - async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise { - const wc = this.requireWebContents(tabId); - await this.withControlSession(tabId, wc, "type", async (send) => { - await send("Runtime.enable"); - const locator = this.automationLocator(input); - if (locator) await this.ensurePlaywrightInjected(send); - const focusResult = await this.evaluateWithDebugger< - { ok: true } | { invalidSelector: true; message: string } | { notFound: true } - >( - send, - `(() => { - try { - const element = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "document.activeElement"}; - if (!element) return { notFound: true }; - element.focus(); - if (${input.clear ?? false}) { - if ("value" in element) element.value = ""; - else if (element.isContentEditable) element.textContent = ""; - element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" })); - } - return { ok: true }; - } catch (error) { - return { invalidSelector: true, message: String(error) }; - } - })()`, - true, - ); - if ("invalidSelector" in focusResult) { - throw automationError("PreviewAutomationInvalidSelectorError", focusResult.message, { - selector: input.selector ?? "", - }); - } - if ("notFound" in focusResult) { - throw automationError( - "PreviewAutomationExecutionError", - locator - ? `No element matches locator ${locator}.` - : "No element is focused in the preview.", - ); - } - await send("Input.insertText", { text: input.text }); - await this.evaluateWithDebugger( - send, - `(() => { - const element = document.activeElement; - element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(input.text)} })); - element?.dispatchEvent(new Event("change", { bubbles: true })); - })()`, - false, - ); - }); - } - - async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise { - const wc = this.requireWebContents(tabId); - await this.withControlSession(tabId, wc, "press", async (send) => { - const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { - switch (modifier) { - case "Alt": - return value | 1; - case "Control": - return value | 2; - case "Meta": - return value | 4; - case "Shift": - return value | 8; - } - }, 0); - const key = input.key; - const text = key.length === 1 ? key : undefined; - const params = { - key, - code: key.length === 1 ? `Key${key.toUpperCase()}` : key, - modifiers, - ...(text ? { text, unmodifiedText: text } : {}), - }; - this.expectAgentInput(tabId, { kind: "key", key, code: params.code }); - await send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); - await send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); - }); - } - - async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise { - const wc = this.requireWebContents(tabId); - await this.withControlSession(tabId, wc, "scroll", async (send) => { - await send("Runtime.enable"); - const locator = this.automationLocator(input); - if (locator) await this.ensurePlaywrightInjected(send); - const result = await this.evaluateWithDebugger< - { ok: true } | { invalidSelector: true; message: string } | { notFound: true } - >( - send, - `(() => { - try { - const target = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "window"}; - if (!target) return { notFound: true }; - target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); - return { ok: true }; - } catch (error) { - return { invalidSelector: true, message: String(error) }; - } - })()`, - true, - ); - if ("invalidSelector" in result) { - throw automationError("PreviewAutomationInvalidSelectorError", result.message, { - selector: input.selector ?? "", - }); - } - if ("notFound" in result) { - throw automationError( - "PreviewAutomationExecutionError", - `No element matches locator ${locator}.`, - ); - } - }); - } - - async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise { - const wc = this.requireWebContents(tabId); - return this.withControlSession(tabId, wc, "evaluate", async (send) => { - await send("Runtime.enable"); - const value = await this.evaluateWithDebugger( - send, - input.expression, - input.returnByValue ?? true, - input.awaitPromise ?? true, - ); - const serialized = JSON.stringify(value); - if ( - serialized !== undefined && - Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES - ) { - throw automationError( - "PreviewAutomationResultTooLargeError", - `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, - { maximumBytes: MAX_EVALUATION_BYTES }, - ); - } - return value; - }); - } - - async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise { - const wc = this.requireWebContents(tabId); - const timeoutMs = input.timeoutMs ?? 15_000; - await this.withControlSession(tabId, wc, "waitFor", async (send) => { - await send("Runtime.enable"); - const locator = this.automationLocator(input); - if (locator) await this.ensurePlaywrightInjected(send); - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - const result = await this.evaluateWithDebugger< - { matched: boolean } | { invalidSelector: true; message: string } - >( - send, - `(() => { - try { - const selectorMatched = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, false) !== null; })()` : "true"}; - const textMatched = ${ - input.text - ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})` - : "true" - }; - const urlMatched = ${ - input.urlIncludes - ? `location.href.includes(${JSON.stringify(input.urlIncludes)})` - : "true" - }; - return { matched: selectorMatched && textMatched && urlMatched }; - } catch (error) { - return { invalidSelector: true, message: String(error) }; - } - })()`, - true, - ); - if ("invalidSelector" in result) { - throw automationError("PreviewAutomationInvalidSelectorError", result.message, { - selector: input.selector ?? "", - }); - } - if (result.matched) return; - await sleep(100); - } - throw automationError( - "PreviewAutomationTimeoutError", - `Preview condition did not match within ${timeoutMs}ms.`, - ); - }); - } - - private applyZoom(tabId: string, transform: (current: number) => number): void { - const tab = this.tabs.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()) { - wc.setZoomFactor(next); - } - } - this.update(tabId, { zoomFactor: next }); - } - - private async withControlSession( - tabId: string, - wc: Electron.WebContents, - action: string, - use: ( - send: (method: string, commandParams?: Record) => Promise, - ) => Promise, - ): Promise { - const actionEvent: PreviewAutomationActionEvent = { - id: `browser-action-${Date.now().toString(36)}-${(this.actionSequence++).toString(36)}`, - action, - status: "running", - startedAt: new Date().toISOString(), - }; - this.pushAction(tabId, actionEvent); - const epoch = this.controlEpoch.get(tabId) ?? 0; - const control = await this.ensureControlSession(wc); - let resolveTail: () => void = () => undefined; - const previous = control.tail; - control.tail = new Promise((resolve) => { - resolveTail = resolve; - }); - await previous; - this.update(tabId, { controller: "agent" }); - try { - const send = async (method: string, commandParams?: Record) => { - if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) { - throw automationError( - "PreviewAutomationControlInterruptedError", - "Browser control was interrupted by human input.", - ); - } - const result = await wc.debugger.sendCommand(method, commandParams); - if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) { - throw automationError( - "PreviewAutomationControlInterruptedError", - "Browser control was interrupted by human input.", - ); - } - return result; - }; - const result = await use(send); - this.replaceAction(tabId, { - ...actionEvent, - status: "succeeded", - completedAt: new Date().toISOString(), - }); - return result; - } catch (cause) { - const interrupted = - cause instanceof Error && cause.name === "PreviewAutomationControlInterruptedError"; - this.replaceAction(tabId, { - ...actionEvent, - status: interrupted ? "interrupted" : "failed", - completedAt: new Date().toISOString(), - error: cause instanceof Error ? cause.message : String(cause), - }); - if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause; - throw automationError( - "PreviewAutomationExecutionError", - cause instanceof Error ? cause.message : String(cause), - { tabId, cause }, - ); - } finally { - if (this.tabs.has(tabId)) this.update(tabId, { controller: "none" }); - resolveTail(); - } - } - - private pushAction(tabId: string, event: PreviewAutomationActionEvent): void { - const timeline = this.actionTimeline.get(tabId) ?? []; - timeline.push(event); - if (timeline.length > 200) timeline.splice(0, timeline.length - 200); - this.actionTimeline.set(tabId, timeline); - } - - private replaceAction(tabId: string, event: PreviewAutomationActionEvent): void { - const timeline = this.actionTimeline.get(tabId); - if (!timeline) return; - const index = timeline.findIndex((candidate) => candidate.id === event.id); - if (index >= 0) timeline[index] = event; - } - - private async ensureControlSession(wc: Electron.WebContents): Promise { - const existing = this.controlSessions.get(wc.id); - if (existing) { - await existing.initialized; - return existing; - } - if (wc.isDevToolsOpened()) { - throw automationError( - "PreviewAutomationExecutionError", - "Close preview DevTools before using agent browser control.", - ); - } - if (wc.debugger.isAttached()) { - throw automationError( - "PreviewAutomationExecutionError", - "Preview control cannot attach because another debugger owns this page.", - ); - } - const control: BrowserControlSession = { - webContentsId: wc.id, - tail: Promise.resolve(), - initialized: Promise.resolve(), - }; - const diagnostics: BrowserDiagnostics = { - consoleEntries: [], - networkEntries: [], - requests: new Map(), - }; - this.diagnostics.set(wc.id, diagnostics); - wc.debugger.on("message", (_event, method, params) => { - if (method === "Page.screencastFrame") { - const frame = params as Record; - const sessionId = frame["sessionId"]; - if (typeof sessionId === "number") { - void wc.debugger - .sendCommand("Page.screencastFrameAck", { sessionId }) - .catch(() => undefined); - } - const tabId = this.tabIdForWebContents(wc.id); - const metadata = - typeof frame["metadata"] === "object" && frame["metadata"] !== null - ? (frame["metadata"] as Record) - : {}; - if (tabId && typeof frame["data"] === "string") { - const payload: DesktopPreviewRecordingFrame = { - tabId, - data: frame["data"], - width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, - height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, - receivedAt: new Date().toISOString(), - }; - for (const listener of this.recordingFrameListeners) listener(payload); - } - } - this.captureDiagnosticMessage(diagnostics, method, params as Record); - }); - control.initialized = (async () => { - wc.debugger.attach("1.3"); - await Promise.all([ - wc.debugger.sendCommand("Runtime.enable"), - wc.debugger.sendCommand("Accessibility.enable"), - wc.debugger.sendCommand("Network.enable"), - wc.debugger.sendCommand("Log.enable"), - ]); - })(); - this.controlSessions.set(wc.id, control); - try { - await control.initialized; - return control; - } catch (cause) { - this.controlSessions.delete(wc.id); - throw cause; - } - } - - private detachControlSession(webContentsId: number): void { - this.controlSessions.delete(webContentsId); - this.diagnostics.delete(webContentsId); - const wc = webContents.fromId(webContentsId); - if (!wc || wc.isDestroyed() || !wc.debugger.isAttached()) return; - try { - wc.debugger.detach(); - } catch { - // Target teardown can race detachment. - } - } - - private tabIdForWebContents(webContentsId: number): string | null { - for (const [tabId, tab] of this.tabs) { - if (tab.webContentsId === webContentsId) return tabId; - } - return null; - } - - private captureDiagnosticMessage( - diagnostics: BrowserDiagnostics, - method: string, - params: Record, - ): void { - const timestamp = new Date().toISOString(); - if (method === "Runtime.consoleAPICalled") { - const args = Array.isArray(params["args"]) ? params["args"] : []; - const text = args - .map((arg) => { - if (typeof arg !== "object" || arg === null) return String(arg); - const value = arg as Record; - return String(value["value"] ?? value["description"] ?? ""); - }) - .join(" "); - this.pushBounded(diagnostics.consoleEntries, { - level: typeof params["type"] === "string" ? params["type"] : "log", - text, - timestamp, - source: "console", - }); - return; - } - if (method === "Runtime.exceptionThrown") { - const details = - typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null - ? (params["exceptionDetails"] as Record) - : {}; - this.pushBounded(diagnostics.consoleEntries, { - level: "error", - text: String(details["text"] ?? "Uncaught exception"), - timestamp, - source: "exception", - }); - return; - } - if (method === "Log.entryAdded") { - const entry = - typeof params["entry"] === "object" && params["entry"] !== null - ? (params["entry"] as Record) - : {}; - this.pushBounded(diagnostics.consoleEntries, { - level: typeof entry["level"] === "string" ? entry["level"] : "info", - text: String(entry["text"] ?? ""), - timestamp, - source: typeof entry["source"] === "string" ? entry["source"] : "log", - }); - return; - } - const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null; - if (method === "Network.requestWillBeSent" && requestId) { - const request = - typeof params["request"] === "object" && params["request"] !== null - ? (params["request"] as Record) - : {}; - diagnostics.requests.set(requestId, { - url: String(request["url"] ?? ""), - method: String(request["method"] ?? "GET"), - }); - return; - } - if (method === "Network.responseReceived" && requestId) { - const request = diagnostics.requests.get(requestId); - const response = - typeof params["response"] === "object" && params["response"] !== null - ? (params["response"] as Record) - : {}; - const status = typeof response["status"] === "number" ? response["status"] : null; - if (request && status !== null && status >= 400) { - this.pushBounded(diagnostics.networkEntries, { - ...request, - status, - failed: true, - timestamp, - }); - } - return; - } - if (method === "Network.loadingFailed" && requestId) { - const request = diagnostics.requests.get(requestId); - if (request) { - this.pushBounded(diagnostics.networkEntries, { - ...request, - status: null, - failed: true, - errorText: String(params["errorText"] ?? "Network request failed"), - timestamp, - }); - } - diagnostics.requests.delete(requestId); - return; - } - if (method === "Network.loadingFinished" && requestId) diagnostics.requests.delete(requestId); - } - - private pushBounded(buffer: A[], entry: A): void { - buffer.push(entry); - if (buffer.length > DIAGNOSTIC_BUFFER_LIMIT) { - buffer.splice(0, buffer.length - DIAGNOSTIC_BUFFER_LIMIT); - } - } - - private async evaluateWithDebugger( - send: (method: string, commandParams?: Record) => Promise, - expression: string, - returnByValue: boolean, - awaitPromise = true, - ): Promise { - const response = (await send("Runtime.evaluate", { - expression, - awaitPromise, - returnByValue, - userGesture: true, - })) as CdpEvaluationResult; - if (response.exceptionDetails) { - throw automationError( - "PreviewAutomationExecutionError", - response.exceptionDetails.exception?.description ?? - response.exceptionDetails.text ?? - "JavaScript evaluation failed.", - ); - } - return response.result?.value as A; - } - - private automationLocator(input: { - readonly selector?: string | undefined; - readonly locator?: string | undefined; - }): string | null { - if (input.locator) return input.locator; - if (input.selector) return `css=${input.selector}`; - return null; - } - - private async ensurePlaywrightInjected( - send: (method: string, commandParams?: Record) => Promise, - ): Promise { - const installed = await this.evaluateWithDebugger( - send, - "Boolean(globalThis.__t3PlaywrightInjected)", - true, - ); - if (installed) return; - const expression = await playwrightInjectedRuntimeInstallExpression(); - await this.evaluateWithDebugger(send, expression, true); - } - - onStateChange(listener: Listener): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - onPointerEvent(listener: PointerEventListener): () => void { - this.pointerEventListeners.add(listener); - return () => { - this.pointerEventListeners.delete(listener); - }; - } - - private emitPointerEvent(event: DesktopPreviewPointerEvent): void { - for (const listener of this.pointerEventListeners) listener(event); - } - - private expectAgentInput(tabId: string, signal: PreviewInputSignal): void { - const now = Date.now(); - const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter( - (expected) => expected.expiresAt > now, - ); - pending.push({ signal, expiresAt: now + 1_000 }); - this.expectedAgentInputs.set(tabId, pending); - } - - private consumeExpectedAgentInput(tabId: string, signal: PreviewInputSignal): boolean { - const now = Date.now(); - const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter( - (expected) => expected.expiresAt > now, - ); - const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal)); - if (index < 0) { - if (pending.length === 0) this.expectedAgentInputs.delete(tabId); - else this.expectedAgentInputs.set(tabId, pending); - return false; - } - pending.splice(index, 1); - if (pending.length === 0) this.expectedAgentInputs.delete(tabId); - else this.expectedAgentInputs.set(tabId, pending); - return true; - } - - destroy(): void { - for (const tabId of Array.from(this.tabs.keys())) { - this.closeTab(tabId); - } - this.listeners.clear(); - this.expectedAgentInputs.clear(); - this.pointerEventListeners.clear(); - this.recordingFrameListeners.clear(); - } - - private attachListeners(tabId: string, wc: Electron.WebContents): void { - const sync = () => { - if (wc.isDestroyed()) return; - this.update(tabId, { - navStatus: this.computeNavStatus(wc), - canGoBack: wc.navigationHistory.canGoBack(), - canGoForward: wc.navigationHistory.canGoForward(), - }); - }; - const failed = (_event: Event, code: number, description: string): void => { - // -3 = ABORTED (user navigated away mid-load); ignore. - if (code === -3) return; - this.update(tabId, { - navStatus: { - kind: "LoadFailed", - url: wc.getURL(), - title: wc.getTitle(), - code, - description, - }, - }); - }; - const humanInput = (_event: unknown, rawSignal?: unknown): void => { - if (isPreviewInputSignal(rawSignal) && this.consumeExpectedAgentInput(tabId, rawSignal)) { - return; - } - this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1); - this.update(tabId, { controller: "human" }); - void sleep(750).then(() => { - if (this.tabs.get(tabId)?.controller === "human") { - this.update(tabId, { controller: "none" }); - } - }); - }; - - wc.on("did-navigate", sync); - wc.on("did-navigate-in-page", sync); - wc.on("page-title-updated", sync); - wc.on("did-start-loading", sync); - wc.on("did-stop-loading", sync); - wc.on("did-fail-load", failed as never); - wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); - - // Keep external links inside the same view (matches ami's policy). - wc.setWindowOpenHandler(({ url }) => { - void wc.loadURL(url); - return { action: "deny" }; - }); - - // Forward app-level shortcuts to the main window so mod+shift+J etc. - // still toggles the preview panel even when the webview has focus. - wc.on("before-input-event", (event, input) => { - if (this.isAppShortcut(input) && this.mainWindow && !this.mainWindow.isDestroyed()) { - event.preventDefault(); - this.mainWindow.webContents.sendInputEvent({ - type: "keyDown", - keyCode: input.key, - modifiers: [ - ...(input.meta ? (["meta"] as const) : []), - ...(input.shift ? (["shift"] as const) : []), - ...(input.control ? (["control"] as const) : []), - ...(input.alt ? (["alt"] as const) : []), - ], - }); - } - }); - - this.attached.set(wc.id, { navigate: sync, failed, humanInput }); - } - - private detachListeners(webContentsId: number): void { - const handlers = this.attached.get(webContentsId); - if (!handlers) return; - this.attached.delete(webContentsId); - const wc = webContents.fromId(webContentsId); - if (!wc || wc.isDestroyed()) return; - wc.off("did-navigate", handlers.navigate); - wc.off("did-navigate-in-page", handlers.navigate); - wc.off("page-title-updated", handlers.navigate); - wc.off("did-start-loading", handlers.navigate); - wc.off("did-stop-loading", handlers.navigate); - wc.off("did-fail-load", handlers.failed as never); - wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput); - } - - private isAppShortcut(input: Electron.Input): boolean { - if (input.type !== "keyDown") return false; - return APP_FORWARDED_SHORTCUTS.some( - (shortcut) => - shortcut.key.toLowerCase() === input.key.toLowerCase() && - shortcut.meta === input.meta && - shortcut.shift === input.shift && - shortcut.control === input.control, - ); - } - - private computeNavStatus(wc: Electron.WebContents): PreviewNavStatus { - const url = wc.getURL(); - const title = wc.getTitle(); - if (url === "" || url === "about:blank") return { kind: "Idle" }; - if (wc.isLoading()) return { kind: "Loading", url, title }; - return { kind: "Success", url, title }; - } - - private requireWebContents(tabId: string): Electron.WebContents { - const tab = this.tabs.get(tabId); - if (!tab) throw new PreviewTabNotFoundError(tabId); - if (tab.webContentsId == null) throw new PreviewWebviewNotInitializedError(tabId); - const wc = webContents.fromId(tab.webContentsId); - if (!wc) throw new PreviewWebContentsNotFoundError(tabId, tab.webContentsId); - return wc; - } - - private update(tabId: string, patch: Partial): void { - const current = this.tabs.get(tabId); - if (!current) return; - const next: PreviewTabState = { - ...current, - ...patch, - updatedAt: new Date().toISOString(), - }; - this.tabs.set(tabId, next); - this.emit(tabId, next); - } - - private emit(tabId: string, state: PreviewTabState): void { - for (const listener of this.listeners) { - try { - listener(tabId, state); - } catch { - // listener errors must not crash the manager - } - } - } - - private normalizeUrl(input: string): string { - // Surface the shared error directly so the IPC caller (and any future - // desktop-side telemetry) gets the `detail` field for free. Defining a - // bespoke desktop class would just lose information. - return normalizePreviewUrl(input); - } -} - -export class PreviewTabNotFoundError extends Error { - readonly tabId: string; - constructor(tabId: string) { - super(`Preview tab not found: ${tabId}`); - this.name = "PreviewTabNotFoundError"; - this.tabId = tabId; - } -} - -export class PreviewWebContentsNotFoundError extends Error { - readonly tabId: string; - readonly webContentsId: number; - constructor(tabId: string, webContentsId: number) { - super(`WebContents ${webContentsId} not found for preview tab ${tabId}`); - this.name = "PreviewWebContentsNotFoundError"; - this.tabId = tabId; - this.webContentsId = webContentsId; - } -} - -export class PreviewWebviewNotInitializedError extends Error { - readonly tabId: string; - constructor(tabId: string) { - super(`Preview tab "${tabId}" has no webview registered`); - this.name = "PreviewWebviewNotInitializedError"; - this.tabId = tabId; - } -} - -export const previewViewManager = new PreviewViewManager(); diff --git a/apps/desktop/src/preview-webview-preferences.test.ts b/apps/desktop/src/preview-webview-preferences.test.ts deleted file mode 100644 index dc88714a51dc02b20f40e6aeb250e6a3418bae48..0000000000000000000000000000000000000000 --- a/apps/desktop/src/preview-webview-preferences.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; - -/** - * Mirrors Electron's webview attribute parser closely enough to catch the - * regressions we've already hit: - * - * - whitespace inside the comma-separated list silently drops keys (so - * `" sandbox=true"` becomes an unknown key and Electron falls back to - * defaults — re-opening the Node-leak window we closed), - * - non-`true`/`false` values (`"yes"`, `"no"`, etc.) are kept as truthy - * strings and assigned to a boolean preference, which silently flips - * `contextIsolation=no` to ENABLED (then react-grab can't see the React - * DevTools hook and componentName resolution always returns null). - * - * The actual Electron parser does roughly: - * - * for (const pair of webpreferences.split(',')) { - * const [key, value] = pair.split('='); - * prefs[key] = value; // value left as a string - * } - * - * then later coerces booleans via `Boolean(value)`. Replicating that here - * keeps the test independent of Electron internals while still failing if - * we accidentally ship `"contextIsolation=no"` again. - */ -function parseWebPreferences(input: string): Record { - const out: Record = {}; - for (const pair of input.split(",")) { - if (pair !== pair.trim()) { - // Electron's parser doesn't trim; surface the bug as undefined-key. - out[pair] = pair.split("=")[1]; - continue; - } - const [key, value] = pair.split("="); - if (!key) continue; - out[key] = value; - } - return out; -} - -describe("PREVIEW_WEBVIEW_PREFERENCES", () => { - const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES); - - it("contains exactly the three security-critical keys", () => { - expect(Object.keys(parsed).toSorted()).toEqual( - ["contextIsolation", "nodeIntegration", "sandbox"].toSorted(), - ); - }); - - it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => { - // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so - // `contextIsolation="no"` would silently leave isolation ENABLED. Lock - // the values to `"true"` / `"false"` so the parser does the right thing. - for (const value of Object.values(parsed)) { - expect(value).toMatch(/^(true|false)$/); - } - }); - - it("disables context isolation (so react-grab can see the page's React DevTools hook)", () => { - expect(parsed["contextIsolation"]).toBe("false"); - }); - - it("keeps the renderer sandbox enabled (so the page cannot reach Node APIs)", () => { - expect(parsed["sandbox"]).toBe("true"); - }); - - it("disables nodeIntegration (defense in depth — page never gets Node)", () => { - expect(parsed["nodeIntegration"]).toBe("false"); - }); - - it("contains no whitespace (Electron's parser does not trim)", () => { - // Electron splits on `,` without trimming, so any whitespace would turn - // a key into an unknown one and silently drop the security flag. - expect(PREVIEW_WEBVIEW_PREFERENCES).not.toMatch(/\s/); - }); -}); diff --git a/apps/desktop/src/preview-webview-preferences.ts b/apps/desktop/src/preview-webview-preferences.ts deleted file mode 100644 index 2d96bff456c526a0696810474ccfa01678a6e415..0000000000000000000000000000000000000000 --- a/apps/desktop/src/preview-webview-preferences.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * webPreferences override applied to every preview `` element via - * its `webpreferences="..."` attribute. Single source of truth so all guest - * surfaces inherit the same security posture. - * - * Lives in its own electron-free module so the value is unit-testable - * without importing `preview-view-manager.ts` (which transitively imports - * `electron` and blows up under vitest). - * - * - `contextIsolation=false`: the picker preload needs to share `globalThis` - * with the page so react-grab/bippy can read the React DevTools hook - * (`__REACT_DEVTOOLS_GLOBAL_HOOK__`) and resolve component names. Without - * this every pick comes back with `componentName: null` even on dev React - * apps. - * - `sandbox=true`: keeps the OS-level renderer sandbox enabled. Critical - * when paired with `contextIsolation=false` — without sandbox, the preload - * has full Node access (`require`, `fs`, `child_process`, ...) and that - * `require` would land on the page's shared `globalThis`, giving any - * third-party page in the preview full Node + IPC access to the host. - * In sandboxed mode Electron still synthesizes the `electron` module for - * the preload's `import { ipcRenderer }` line, but no Node globals leak. - * - `nodeIntegration=false`: pinned for clarity (the page itself never gets - * Node access). - * - * Format notes (locked down by `preview-webview-preferences.test.ts`): - * - Whitespace-free. Electron's webpreferences parser splits on `,` and - * does not trim, so a leading space would turn a key into an unknown one - * and silently drop it. - * - Values are JS-boolean strings (`true`/`false`) — `yes`/`no` are not - * special-cased by the parser; `value="no"` becomes the truthy STRING - * `"no"` when assigned to a boolean webPreferences key. Most critically, - * `contextIsolation="no"` is truthy → contextIsolation stays ENABLED → - * react-grab can't see the React DevTools hook. - * - * Defense in depth: `apps/desktop/src/main.ts` also runs a - * `will-attach-webview` handler that force-sets `sandbox: true` and - * `nodeIntegration*: false` on the actual webPreferences object, gated on - * the preview partition, so even if this string is ever wrong, the - * security-critical flags can't regress on preview tabs. - */ -export const PREVIEW_WEBVIEW_PREFERENCES = - "contextIsolation=false,sandbox=true,nodeIntegration=false"; diff --git a/apps/desktop/src/preview/Annotation.css b/apps/desktop/src/preview/Annotation.css new file mode 100644 index 0000000000000000000000000000000000000000..89676a22d585b21417f195fbc09e864a1e0c102a --- /dev/null +++ b/apps/desktop/src/preview/Annotation.css @@ -0,0 +1,68 @@ +@import "tailwindcss"; + +@theme inline { + --font-sans: var(--t3-font-sans); + --font-mono: var(--t3-font-mono); + --color-background: var(--t3-background); + --color-foreground: var(--t3-foreground); + --color-popover: var(--t3-popover); + --color-popover-foreground: var(--t3-popover-foreground); + --color-primary: var(--t3-primary); + --color-primary-foreground: var(--t3-primary-foreground); + --color-muted: var(--t3-muted); + --color-muted-foreground: var(--t3-muted-foreground); + --color-accent: var(--t3-accent); + --color-accent-foreground: var(--t3-accent-foreground); + --color-border: var(--t3-border); + --color-input: var(--t3-input); + --color-ring: var(--t3-ring); + --radius-sm: calc(var(--t3-radius) - 4px); + --radius-md: calc(var(--t3-radius) - 2px); + --radius-lg: var(--t3-radius); + --radius-xl: calc(var(--t3-radius) + 4px); + --radius-2xl: calc(var(--t3-radius) + 8px); +} + +:host { + --t3-font-sans: + "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, + sans-serif; + --t3-font-mono: + "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; + --t3-radius: 0.625rem; + --t3-background: white; + --t3-foreground: oklch(0.269 0 0); + --t3-popover: white; + --t3-popover-foreground: oklch(0.269 0 0); + --t3-primary: oklch(0.488 0.217 264); + --t3-primary-foreground: white; + --t3-muted: rgb(0 0 0 / 4%); + --t3-muted-foreground: oklch(0.556 0 0); + --t3-accent: rgb(0 0 0 / 4%); + --t3-accent-foreground: oklch(0.269 0 0); + --t3-border: rgb(0 0 0 / 8%); + --t3-input: rgb(0 0 0 / 10%); + --t3-ring: oklch(0.488 0.217 264); + color: var(--t3-foreground); + font-family: var(--t3-font-sans); +} + +* { + box-sizing: border-box; + border-color: var(--t3-border); +} + +button, +input, +select, +textarea { + font: inherit; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent); + outline-offset: 1px; +} diff --git a/apps/desktop/src/preview/AnnotationStyles.generated.ts b/apps/desktop/src/preview/AnnotationStyles.generated.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b6b73c8ba7823822b484271ff16a69556825567 --- /dev/null +++ b/apps/desktop/src/preview/AnnotationStyles.generated.ts @@ -0,0 +1,3 @@ +// Generated by scripts/build-preview-annotation-css.mjs. Do not edit. +export const previewAnnotationStyles = + '/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: calc(var(--spacing) * 0);\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: calc(var(--spacing) * 0);\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: calc(var(--spacing) * 1);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: calc(var(--spacing) * 0);\n}\n.p-1 {\n padding: calc(var(--spacing) * 1);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: calc(var(--spacing) * 0);\n}\n.px-1 {\n padding-inline: calc(var(--spacing) * 1);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: calc(var(--spacing) * 1);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts new file mode 100644 index 0000000000000000000000000000000000000000..58ca30f3088106aa6007f26cb562bb69b0b36bbf --- /dev/null +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -0,0 +1,93 @@ +import type { Session } from "electron"; +import { session } from "electron"; +import { createHash } from "node:crypto"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; + +export class BrowserSessionError extends Data.TaggedError("BrowserSessionError")<{ + readonly operation: string; + readonly cause: unknown; +}> { + override get message() { + return `Desktop preview browser session operation failed: ${this.operation}`; + } +} + +export interface BrowserSessionShape { + readonly getPartition: (scope?: string) => string; + readonly isPartition: (partition: string) => boolean; + readonly getSession: (scope?: string) => Effect.Effect; + readonly clearCookies: () => Effect.Effect; + readonly clearCache: () => Effect.Effect; +} + +export class BrowserSession extends Context.Service()( + "@t3tools/desktop/preview/BrowserSession", +) {} + +const make = Effect.fn("BrowserSession.make")(() => + Effect.sync(() => { + const sessions = new Map(); + const getPartition = (scope = "shared"): string => { + const digest = createHash("sha256").update(scope).digest("hex").slice(0, 20); + return `${PREVIEW_PARTITION_PREFIX}${digest}`; + }; + + const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") { + const partition = getPartition(scope); + const existing = sessions.get(partition); + if (existing) return existing; + return yield* Effect.try({ + try: () => { + const browserSession = session.fromPartition(partition); + const userAgent = browserSession + .getUserAgent() + .replace(/Electron\/[\d.]+ /, "") + .replace(/\s*t3code\/[\d.]+/, ""); + browserSession.setUserAgent(userAgent); + browserSession.setPermissionRequestHandler((_webContents, permission, callback) => { + const allowed = ["clipboard-read", "clipboard-write", "notifications", "geolocation"]; + callback(allowed.includes(permission)); + }); + sessions.set(partition, browserSession); + return browserSession; + }, + catch: (cause) => new BrowserSessionError({ operation: "getSession", cause }), + }); + }); + + return BrowserSession.of({ + getPartition, + isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX), + getSession, + clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () { + yield* Effect.tryPromise({ + try: () => + Promise.all( + [...sessions.values()].map((browserSession) => + browserSession.clearStorageData({ + storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], + }), + ), + ), + catch: (cause) => new BrowserSessionError({ operation: "clearCookies", cause }), + }); + }), + clearCache: Effect.fn("BrowserSession.clearCache")(function* () { + yield* Effect.tryPromise({ + try: () => + Promise.all( + [...sessions.values()].map((browserSession) => browserSession.clearCache()), + ), + catch: (cause) => new BrowserSessionError({ operation: "clearCache", cause }), + }); + }), + }); + }), +); + +export const layer = Layer.effect(BrowserSession, make()); diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts new file mode 100644 index 0000000000000000000000000000000000000000..00616c6a4761f325a2e604ac667f265da65aeaec --- /dev/null +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -0,0 +1,6 @@ +export const START_PICK_CHANNEL = "preview:start-pick"; +export const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; +export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; +export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; +export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; +export const HUMAN_INPUT_CHANNEL = "preview:human-input"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e07d556783dd125c860376b0343a597fb13e899 --- /dev/null +++ b/apps/desktop/src/preview/Manager.test.ts @@ -0,0 +1,283 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const fromId = vi.fn(() => null); +const mkdir = vi.fn(async () => undefined); +const writeFile = vi.fn(async () => undefined); +const showItemInFolder = vi.fn(); +const writeImage = vi.fn(); +const createFromPath = vi.fn(() => ({ isEmpty: () => false })); +const webviewSend = vi.fn(); + +vi.mock("node:fs/promises", () => ({ mkdir, writeFile })); + +vi.mock("electron", () => ({ + clipboard: { + writeImage, + }, + nativeImage: { + createFromPath, + }, + session: { + fromPartition: vi.fn(), + }, + shell: { + showItemInFolder, + }, + webContents: { + fromId, + }, +})); + +describe("PreviewViewManager automation status", () => { + beforeEach(() => { + fromId.mockClear(); + mkdir.mockClear(); + writeFile.mockClear(); + showItemInFolder.mockClear(); + writeImage.mockClear(); + createFromPath.mockClear(); + webviewSend.mockClear(); + }); + + it("reports an unregistered webview as temporarily unavailable", async () => { + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; + const manager = new PreviewViewManager(); + + expect(manager.automationStatus("tab_1")).toEqual({ + available: false, + visible: true, + tabId: "tab_1", + url: null, + title: null, + loading: false, + }); + + manager.createTab("tab_1"); + + expect(manager.automationStatus("tab_1")).toEqual({ + available: false, + visible: true, + tabId: "tab_1", + url: null, + title: null, + loading: false, + }); + expect(fromId).not.toHaveBeenCalled(); + }); + + it("captures a PNG screenshot into browser artifacts", async () => { + const png = Buffer.from("preview-png"); + const capturePage = vi.fn(async () => ({ toPNG: () => png })); + const listeners = new Map void>(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com:8443/path?query=value", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + capturePage, + } as never); + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; + const manager = new PreviewViewManager(); + manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); + manager.createTab("tab_1"); + manager.registerWebview("tab_1", 42); + + expect(webviewSend).toHaveBeenCalledWith( + "preview:annotation-theme", + expect.objectContaining({ + colorScheme: "light", + primary: "oklch(0.488 0.217 264)", + }), + ); + + const artifact = await manager.captureScreenshot("tab_1"); + + expect(capturePage).toHaveBeenCalledOnce(); + expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts", { + recursive: true, + }); + expect(writeFile).toHaveBeenCalledWith(artifact.path, png); + expect(artifact).toMatchObject({ + tabId: "tab_1", + mimeType: "image/png", + sizeBytes: png.byteLength, + }); + expect(artifact.path).toMatch( + /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, + ); + }); + + it("reveals only files inside the configured browser artifact directory", async () => { + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; + const manager = new PreviewViewManager(); + manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); + + manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"); + + expect(showItemInFolder).toHaveBeenCalledWith( + "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png", + ); + expect(() => manager.revealArtifact("/tmp/t3/dev/settings.json")).toThrow( + "outside the configured artifact directory", + ); + }); + + it("copies screenshot artifacts to the system clipboard", async () => { + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; + const manager = new PreviewViewManager(); + manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); + const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"; + + manager.copyArtifactToClipboard(artifactPath); + + expect(createFromPath).toHaveBeenCalledWith(artifactPath); + expect(writeImage).toHaveBeenCalledOnce(); + expect(() => manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json")).toThrow( + "outside the configured artifact directory", + ); + }); + + it("emits the resolved pointer target before dispatching an automation click", async () => { + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const activity: string[] = []; + const sendCommand = vi.fn(async (method: string, params?: Record) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: { width: 800, height: 600 }, + }, + }; + } + if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { + activity.push("mousePressed"); + humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); + } + return undefined; + }); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; + const manager = new PreviewViewManager(); + manager.onPointerEvent((event) => activity.push(event.phase)); + manager.createTab("tab_1"); + manager.registerWebview("tab_1", 42); + + await manager.automationClick("tab_1", { x: 120, y: 80 }); + + expect(activity).toEqual(["move", "click", "mousePressed"]); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mousePressed", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + }); + + it("still interrupts agent control for a different human pointer event", async () => { + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const sendCommand = vi.fn(async (method: string) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: { width: 800, height: 600 }, + }, + }; + } + if (method === "Input.dispatchMouseEvent") { + humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 }); + } + return undefined; + }); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; + const manager = new PreviewViewManager(); + manager.createTab("tab_1"); + manager.registerWebview("tab_1", 42); + + await expect(manager.automationClick("tab_1", { x: 120, y: 80 })).rejects.toMatchObject({ + name: "PreviewAutomationControlInterruptedError", + }); + }); +}); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c62179bf6f87f34e1ffd77a77445b7fecfcaa7d --- /dev/null +++ b/apps/desktop/src/preview/Manager.ts @@ -0,0 +1,1949 @@ +// @effect-diagnostics globalDate:off +// @effect-diagnostics nodeBuiltinImport:off +/** + * PreviewViewManager — desktop side of the in-app browser preview. + * + * Hosts per-tab Chromium WebContents references (the actual + * elements live in the renderer; we only attach listeners and forward state + * here). Single layer-scoped browser session partition. + */ +import type { + DesktopPreviewAnnotationTheme, + DesktopPreviewPointerEvent, + PreviewAnnotationPayload, + PreviewAnnotationRect, + DesktopPreviewRecordingArtifact, + DesktopPreviewRecordingFrame, + DesktopPreviewScreenshotArtifact, + PreviewAutomationClickInput, + PreviewAutomationActionEvent, + PreviewAutomationConsoleEntry, + PreviewAutomationEvaluateInput, + PreviewAutomationPressInput, + PreviewAutomationNetworkEntry, + PreviewAutomationScrollInput, + PreviewAutomationSnapshot, + PreviewAutomationStatus, + PreviewAutomationTypeInput, + PreviewAutomationWaitForInput, +} from "@t3tools/contracts"; +import { normalizePreviewUrl } from "@t3tools/shared/preview"; +import { + type BrowserWindow, + type Session, + clipboard, + nativeImage, + shell, + webContents, +} from "electron"; +import { mkdir, writeFile } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; + +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"; + +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; + controller: "human" | "agent" | "none"; + updatedAt: string; +} + +/** Discrete zoom levels mirroring Chrome's preset list. */ +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 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 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 }; + }; +} + +const automationError = ( + tag: + | "PreviewAutomationExecutionError" + | "PreviewAutomationInvalidSelectorError" + | "PreviewAutomationResultTooLargeError" + | "PreviewAutomationTimeoutError" + | "PreviewAutomationControlInterruptedError", + message: string, + detail?: unknown, +): Error & { detail?: unknown } => { + const error = new Error(message) as Error & { detail?: unknown }; + error.name = tag; + if (detail !== undefined) error.detail = detail; + return error; +}; + +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: 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 = async ( + wc: Electron.WebContents, + cropRect: PreviewAnnotationRect | null, +) => { + const image = await wc.capturePage( + cropRect + ? { + x: cropRect.x, + y: cropRect.y, + width: cropRect.width, + height: cropRect.height, + } + : undefined, + ); + 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 => { + for (let index = 0; index < ZOOM_LEVELS.length; index += 1) { + if (Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON) return index; + if (ZOOM_LEVELS[index]! > current) return index - 1; + } + return ZOOM_LEVELS.length - 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) => void; +type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => 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 { + navigate: () => void; + failed: (event: Event, code: number, description: string) => void; + humanInput: (_event: unknown, signal?: unknown) => void; + beforeInput: (event: Electron.Event, input: Electron.Input) => void; +} + +interface PickSession { + readonly resolve: (payload: PreviewAnnotationPayload | null) => void; + readonly cleanup: () => void; +} + +interface BrowserControlSession { + readonly webContentsId: number; + tail: Promise; + initialized: Promise; + readonly onMessage: ( + event: Electron.Event, + method: string, + params: Record, + ) => void; +} + +interface BrowserDiagnostics { + readonly consoleEntries: PreviewAutomationConsoleEntry[]; + readonly networkEntries: PreviewAutomationNetworkEntry[]; + readonly requests: Map; +} + +type PointerEventListener = (event: DesktopPreviewPointerEvent) => 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 + ); +}; + +class PreviewViewManager { + private annotationTheme = DEFAULT_ANNOTATION_THEME; + private artifactDirectory: string | null = null; + private mainWindow: BrowserWindow | null = null; + private readonly tabs = new Map(); + private readonly attached = new Map(); + private readonly listeners = new Set(); + private readonly pointerEventListeners = new Set(); + private readonly recordingFrameListeners = new Set(); + /** In-flight preview annotation sessions, keyed by tabId. */ + private readonly pickSessions = new Map(); + /** One long-lived CDP attachment and serialized command queue per guest. */ + private readonly controlSessions = new Map(); + private readonly diagnostics = new Map(); + private readonly expectedAgentInputs = new Map(); + private readonly controlEpoch = new Map(); + private readonly actionTimeline = new Map(); + private actionSequence = 0; + private pointerSequence = 0; + private recordingTabId: string | null = null; + + configureArtifactDirectory(directory: string): void { + this.artifactDirectory = resolve(directory); + } + + private requireArtifactDirectory(): string { + if (!this.artifactDirectory) { + throw new Error("Preview artifact directory is not configured."); + } + return this.artifactDirectory; + } + + private resolveArtifactPath(path: string): string { + const directory = this.requireArtifactDirectory(); + const resolvedPath = resolve(path); + const relativePath = relative(directory, resolvedPath); + if ( + relativePath.length === 0 || + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + throw new Error("Preview artifact path is outside the configured artifact directory."); + } + return resolvedPath; + } + + revealArtifact(path: string): void { + const resolvedPath = this.resolveArtifactPath(path); + shell.showItemInFolder(resolvedPath); + } + + copyArtifactToClipboard(path: string): void { + const resolvedPath = this.resolveArtifactPath(path); + const image = nativeImage.createFromPath(resolvedPath); + if (image.isEmpty()) { + throw new Error("Preview artifact could not be loaded as an image."); + } + clipboard.writeImage(image); + } + + setAnnotationTheme(theme: DesktopPreviewAnnotationTheme): void { + this.annotationTheme = theme; + for (const tab of this.tabs.values()) { + if (tab.webContentsId == null) continue; + const wc = webContents.fromId(tab.webContentsId); + if (!wc || wc.isDestroyed()) continue; + wc.send(ANNOTATION_THEME_CHANNEL, theme); + } + } + + setMainWindow(window: BrowserWindow): void { + this.mainWindow = window; + } + + createTab(tabId: string): PreviewTabState { + const existing = this.tabs.get(tabId); + if (existing) return existing; + const initial: PreviewTabState = { + tabId, + webContentsId: null, + navStatus: { kind: "Idle" }, + canGoBack: false, + canGoForward: false, + zoomFactor: DEFAULT_ZOOM_FACTOR, + controller: "none", + updatedAt: new Date().toISOString(), + }; + this.tabs.set(tabId, initial); + this.emit(tabId, initial); + return initial; + } + + closeTab(tabId: string): void { + const tab = this.tabs.get(tabId); + if (!tab) return; + this.cancelPickElement(tabId); + if (tab.webContentsId != null) { + this.detachControlSession(tab.webContentsId); + this.detachListeners(tab.webContentsId); + } + const closed: PreviewTabState = { + ...tab, + webContentsId: null, + navStatus: { kind: "Idle" }, + canGoBack: false, + canGoForward: false, + zoomFactor: DEFAULT_ZOOM_FACTOR, + controller: "none", + updatedAt: new Date().toISOString(), + }; + this.tabs.delete(tabId); + this.emit(tabId, closed); + } + + registerWebview(tabId: string, webContentsId: number): void { + const tab = this.tabs.get(tabId); + if (!tab) { + throw new PreviewTabNotFoundError(tabId); + } + const wc = webContents.fromId(webContentsId); + if (!wc) { + throw new PreviewWebContentsNotFoundError(tabId, webContentsId); + } + // Defence in depth: a malicious renderer could otherwise trick us into + // attaching listeners to the main window's WebContents (or any other + // process) by passing an arbitrary id. + if (wc.getType() !== "webview") { + throw new PreviewWebContentsNotFoundError(tabId, webContentsId); + } + if (this.mainWindow && wc.hostWebContents !== this.mainWindow.webContents) { + throw new PreviewWebContentsNotFoundError(tabId, webContentsId); + } + if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) { + wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); + return; + } + if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { + this.detachControlSession(tab.webContentsId); + this.detachListeners(tab.webContentsId); + // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`. + // Cancel it so the toggle button doesn't get stuck pressed waiting + // forever for a click on a webview that no longer hosts the listener. + this.cancelPickElement(tabId); + } + this.attachListeners(tabId, wc); + void this.ensureControlSession(wc).catch(() => undefined); + // Restore the persisted zoom factor onto the freshly-attached WebContents + // so a thread-switch + remount lands the user back where they were. + if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { + try { + wc.setZoomFactor(tab.zoomFactor); + } catch { + // wc may have been torn down between resolution and call. + } + } + this.update(tabId, { + webContentsId, + navStatus: this.computeNavStatus(wc), + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + zoomFactor: tab.zoomFactor, + }); + wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); + } + + async navigate(tabId: string, rawUrl: string): Promise { + const wc = this.requireWebContents(tabId); + const url = this.normalizeUrl(rawUrl); + if (wc.getURL() === url) { + wc.reload(); + return; + } + await wc.loadURL(url); + } + + goBack(tabId: string): void { + const wc = this.requireWebContents(tabId); + if (wc.navigationHistory.canGoBack()) { + wc.navigationHistory.goBack(); + } + } + + goForward(tabId: string): void { + const wc = this.requireWebContents(tabId); + if (wc.navigationHistory.canGoForward()) { + wc.navigationHistory.goForward(); + } + } + + refresh(tabId: string): void { + const wc = this.requireWebContents(tabId); + wc.reload(); + } + + /** Bypass HTTP cache on the next load — equivalent to Cmd+Shift+R. */ + hardReload(tabId: string): void { + const wc = this.requireWebContents(tabId); + wc.reloadIgnoringCache(); + } + + /** + * Open the guest webview's DevTools, detached so it doesn't steal panel + * area. Idempotent — re-invoking focuses the existing window. + */ + openDevTools(tabId: string): void { + const wc = this.requireWebContents(tabId); + if (wc.isDevToolsOpened()) { + wc.devToolsWebContents?.focus(); + return; + } + this.detachControlSession(wc.id); + wc.once("devtools-closed", () => { + if (!wc.isDestroyed()) void this.ensureControlSession(wc).catch(() => undefined); + }); + wc.openDevTools({ mode: "detach" }); + } + + /** + * Activate annotation mode for `tabId`. Resolves after the guest submits a + * multi-target annotation and the desktop process captures its screenshot, + * or with `null` when the user cancels. + * + * Exactly one pick session may be active per tab — re-invoking while a + * pick is in flight cleanly resolves the old session with `null` first. + */ + async pickElement(tabId: string): Promise { + const wc = this.requireWebContents(tabId); + this.cancelPickElement(tabId); + return new Promise((resolve) => { + // `wc.ipc` is the per-WebContents IpcMain that receives messages the + // webview's preload sends with `ipcRenderer.send(...)`. We use that + // (not the global `wc.on("ipc-message", ...)`, which is for + // `sendToHost` and only fires on the host renderer's + // element) so the main process actually observes the picked payload. + const cleanup = () => { + wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); + wc.off("destroyed", onDestroyed); + wc.off("did-start-navigation", onNavigated); + this.pickSessions.delete(tabId); + }; + const session: PickSession = { resolve, cleanup }; + const settle = (payload: PreviewAnnotationPayload | null) => { + if (this.pickSessions.get(tabId) !== session) return; + cleanup(); + resolve(payload); + }; + const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { + const payload = args[0]; + if (payload == null) { + settle(null); + return; + } + if (!isPreviewAnnotationPayload(payload)) { + settle(null); + return; + } + const cropRect = normalizeCaptureRect(args[1]); + void captureAnnotationScreenshot(wc, cropRect) + .then((screenshot) => settle({ ...payload, screenshot })) + .catch(() => settle(payload)) + .finally(() => { + if (wc.isDestroyed()) return; + try { + wc.send(ANNOTATION_CAPTURED_CHANNEL); + } catch { + // The guest may have navigated while capture was in flight. + } + }); + }; + const onDestroyed = () => settle(null); + const onNavigated = () => settle(null); + wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); + wc.once("destroyed", onDestroyed); + // A page navigation (incl. SPA → same-document) tears down the + // preload's listeners, so we cancel proactively to avoid hanging. + wc.once("did-start-navigation", onNavigated); + this.pickSessions.set(tabId, session); + // Force-focus the guest webContents BEFORE sending start-pick. Without + // this, Electron's input router will deliver the user's first + // mousemove/click to the host renderer (where the pick button lives) + // instead of to the guest's window listeners — manifest as "the + // picker overlay never appears on remote pages I haven't clicked + // into yet". The renderer-side handler in `PreviewView` is responsible + // for restoring focus to the previously-active host element when the + // pick promise resolves so the user's textarea cursor isn't lost. + try { + if (!wc.isFocused()) wc.focus(); + } catch { + // wc may be torn down; the next try/catch settles. + } + try { + wc.send(START_PICK_CHANNEL, this.annotationTheme); + } catch { + settle(null); + } + }); + } + + cancelPickElement(tabId: string): void { + const session = this.pickSessions.get(tabId); + if (!session) return; + session.cleanup(); + // Best-effort: tell the page to dismiss the overlay even if it's still + // alive — keeps the next invoke fresh. + const tab = this.tabs.get(tabId); + if (tab?.webContentsId != null) { + const wc = webContents.fromId(tab.webContentsId); + if (wc && !wc.isDestroyed()) { + try { + wc.send(CANCEL_PICK_CHANNEL); + } catch { + // wc may have been torn down; nothing to clean up. + } + } + } + session.resolve(null); + } + + async startRecording(tabId: string): Promise { + if (this.recordingTabId && this.recordingTabId !== tabId) { + throw new Error("Only one browser recording can be active per window."); + } + const wc = this.requireWebContents(tabId); + await this.withControlSession(tabId, wc, "recording.start", async (send) => { + await send("Page.enable"); + await send("Page.startScreencast", { + format: "jpeg", + quality: 80, + maxWidth: 1600, + maxHeight: 1200, + everyNthFrame: 1, + }); + }); + this.recordingTabId = tabId; + } + + async captureScreenshot(tabId: string): Promise { + const wc = this.requireWebContents(tabId); + const createdAt = new Date().toISOString(); + const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${Date.now().toString(36)}`; + const directory = this.requireArtifactDirectory(); + const path = join(directory, `${id}.png`); + const data = (await wc.capturePage()).toPNG(); + await mkdir(directory, { recursive: true }); + await writeFile(path, data); + return { id, tabId, path, mimeType: "image/png", sizeBytes: data.byteLength, createdAt }; + } + + async stopRecording(tabId: string): Promise { + if (this.recordingTabId !== tabId) return; + const wc = this.requireWebContents(tabId); + await this.withControlSession(tabId, wc, "recording.stop", async (send) => { + await send("Page.stopScreencast"); + }); + this.recordingTabId = null; + } + + async saveRecording( + tabId: string, + mimeType: string, + data: Uint8Array, + ): Promise { + const createdAt = new Date().toISOString(); + const id = `browser-recording-${Date.now().toString(36)}`; + const extension = mimeType.includes("mp4") ? "mp4" : "webm"; + const directory = this.requireArtifactDirectory(); + const path = join(directory, `${id}.${extension}`); + await mkdir(directory, { recursive: true }); + await writeFile(path, data); + return { id, tabId, path, mimeType, sizeBytes: data.byteLength, createdAt }; + } + + onRecordingFrame(listener: RecordingFrameListener): () => void { + this.recordingFrameListeners.add(listener); + return () => this.recordingFrameListeners.delete(listener); + } + + zoomIn(tabId: string): void { + this.applyZoom(tabId, (current) => nextZoomLevel(current, "in")); + } + + zoomOut(tabId: string): void { + this.applyZoom(tabId, (current) => nextZoomLevel(current, "out")); + } + + resetZoom(tabId: string): void { + this.applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR); + } + + automationStatus(tabId: string): PreviewAutomationStatus { + const tab = this.tabs.get(tabId); + if (!tab || tab.webContentsId == null) { + const navStatus = tab?.navStatus; + return { + available: false, + visible: true, + tabId, + url: !navStatus || navStatus.kind === "Idle" ? null : navStatus.url, + title: !navStatus || navStatus.kind === "Idle" ? null : navStatus.title, + loading: navStatus?.kind === "Loading", + }; + } + const wc = webContents.fromId(tab.webContentsId); + if (!wc || wc.isDestroyed()) { + return { + available: false, + visible: true, + tabId, + url: null, + title: null, + loading: false, + }; + } + return { + available: true, + visible: true, + tabId, + url: wc.getURL() || null, + title: wc.getTitle() || null, + loading: wc.isLoading(), + }; + } + + async automationSnapshot(tabId: string): Promise { + const wc = this.requireWebContents(tabId); + return this.withControlSession(tabId, wc, "snapshot", async (send) => { + await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]); + const page = await this.evaluateWithDebugger<{ + url: string; + title: string; + loading: boolean; + visibleText: string; + interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; + }>( + send, + `(() => { + const selectorFor = (element) => { + if (element.id) return "#" + CSS.escape(element.id); + for (const attribute of ["data-testid", "name"]) { + const value = element.getAttribute(attribute); + if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; + } + const parts = []; + let current = element; + while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 8) { + let part = current.tagName.toLowerCase(); + const parent = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName); + if (siblings.length > 1) part += ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")"; + } + parts.unshift(part); + current = parent; + } + return parts.join(" > "); + }; + const visible = (element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; + }; + const elements = Array.from(document.querySelectorAll( + "a[href],button,input,textarea,select,[role],[tabindex]" + )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName.toLowerCase(), + role: element.getAttribute("role"), + name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", + selector: selectorFor(element), + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height + }; + }); + return { + url: location.href, + title: document.title, + loading: document.readyState !== "complete", + visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), + interactiveElements: elements + }; + })()`, + true, + ); + const accessibility = await send("Accessibility.getFullAXTree"); + let image = await wc.capturePage(); + let size = image.getSize(); + if (size.width > MAX_SCREENSHOT_WIDTH) { + image = image.resize({ width: MAX_SCREENSHOT_WIDTH }); + size = image.getSize(); + } + return { + ...page, + accessibilityTree: accessibility, + consoleEntries: [...(this.diagnostics.get(wc.id)?.consoleEntries ?? [])], + networkEntries: [...(this.diagnostics.get(wc.id)?.networkEntries ?? [])], + actionTimeline: [...(this.actionTimeline.get(tabId) ?? [])], + screenshot: { + mimeType: "image/png", + data: image.toPNG().toString("base64"), + width: size.width, + height: size.height, + }, + }; + }); + } + + async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise { + const wc = this.requireWebContents(tabId); + await this.withControlSession(tabId, wc, "click", async (send) => { + await Promise.all([ + send("Runtime.enable"), + send("Input.setIgnoreInputEvents", { ignore: false }), + ]); + let x: number; + let y: number; + if ("selector" in input || "locator" in input) { + await this.ensurePlaywrightInjected(send); + const locator = this.automationLocator(input); + const point = await this.evaluateWithDebugger< + { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } + >( + send, + `(() => { + try { + const injected = globalThis.__t3PlaywrightInjected; + const parsed = injected.parseSelector(${JSON.stringify(locator)}); + const element = injected.querySelector(parsed, document, true); + if (!element) return { notFound: true }; + const visible = injected.elementState(element, "visible"); + const enabled = injected.elementState(element, "enabled"); + if (!visible.matches || !enabled.matches) return { notFound: true }; + element.scrollIntoView({ block: "center", inline: "center" }); + const rect = element.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in point) { + throw automationError("PreviewAutomationInvalidSelectorError", point.message, { + selector: locator, + }); + } + if ("notFound" in point) { + throw automationError( + "PreviewAutomationExecutionError", + `No element matches locator ${locator}.`, + ); + } + x = point.x; + y = point.y; + } else { + x = input.x!; + y = input.y!; + } + const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>( + send, + "({ width: window.innerWidth, height: window.innerHeight })", + true, + ); + if (x < 0 || y < 0 || x > viewport.width || y > viewport.height) { + throw automationError( + "PreviewAutomationExecutionError", + `Click coordinates (${x}, ${y}) are outside the preview viewport.`, + ); + } + this.emitPointerEvent({ + tabId, + phase: "move", + x, + y, + sequence: this.pointerSequence++, + createdAt: new Date().toISOString(), + }); + await sleep(AGENT_CURSOR_MOVE_MS); + this.emitPointerEvent({ + tabId, + phase: "click", + x, + y, + sequence: this.pointerSequence++, + createdAt: new Date().toISOString(), + }); + await sleep(AGENT_CURSOR_CLICK_LEAD_MS); + this.expectAgentInput(tabId, { kind: "pointer", x, y, button: 0 }); + await send("Input.dispatchMouseEvent", { + type: "mousePressed", + x, + y, + button: "left", + clickCount: 1, + }); + await send("Input.dispatchMouseEvent", { + type: "mouseReleased", + x, + y, + button: "left", + clickCount: 1, + }); + }); + } + + async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise { + const wc = this.requireWebContents(tabId); + await this.withControlSession(tabId, wc, "type", async (send) => { + await send("Runtime.enable"); + const locator = this.automationLocator(input); + if (locator) await this.ensurePlaywrightInjected(send); + const focusResult = await this.evaluateWithDebugger< + { ok: true } | { invalidSelector: true; message: string } | { notFound: true } + >( + send, + `(() => { + try { + const element = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "document.activeElement"}; + if (!element) return { notFound: true }; + element.focus(); + if (${input.clear ?? false}) { + if ("value" in element) element.value = ""; + else if (element.isContentEditable) element.textContent = ""; + element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" })); + } + return { ok: true }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in focusResult) { + throw automationError("PreviewAutomationInvalidSelectorError", focusResult.message, { + selector: input.selector ?? "", + }); + } + if ("notFound" in focusResult) { + throw automationError( + "PreviewAutomationExecutionError", + locator + ? `No element matches locator ${locator}.` + : "No element is focused in the preview.", + ); + } + await send("Input.insertText", { text: input.text }); + await this.evaluateWithDebugger( + send, + `(() => { + const element = document.activeElement; + element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(input.text)} })); + element?.dispatchEvent(new Event("change", { bubbles: true })); + })()`, + false, + ); + }); + } + + async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise { + const wc = this.requireWebContents(tabId); + await this.withControlSession(tabId, wc, "press", async (send) => { + const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { + switch (modifier) { + case "Alt": + return value | 1; + case "Control": + return value | 2; + case "Meta": + return value | 4; + case "Shift": + return value | 8; + } + }, 0); + const key = input.key; + const text = key.length === 1 ? key : undefined; + const params = { + key, + code: key.length === 1 ? `Key${key.toUpperCase()}` : key, + modifiers, + ...(text ? { text, unmodifiedText: text } : {}), + }; + this.expectAgentInput(tabId, { kind: "key", key, code: params.code }); + await send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); + await send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); + }); + } + + async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise { + const wc = this.requireWebContents(tabId); + await this.withControlSession(tabId, wc, "scroll", async (send) => { + await send("Runtime.enable"); + const locator = this.automationLocator(input); + if (locator) await this.ensurePlaywrightInjected(send); + const result = await this.evaluateWithDebugger< + { ok: true } | { invalidSelector: true; message: string } | { notFound: true } + >( + send, + `(() => { + try { + const target = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "window"}; + if (!target) return { notFound: true }; + target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); + return { ok: true }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in result) { + throw automationError("PreviewAutomationInvalidSelectorError", result.message, { + selector: input.selector ?? "", + }); + } + if ("notFound" in result) { + throw automationError( + "PreviewAutomationExecutionError", + `No element matches locator ${locator}.`, + ); + } + }); + } + + async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise { + const wc = this.requireWebContents(tabId); + return this.withControlSession(tabId, wc, "evaluate", async (send) => { + await send("Runtime.enable"); + const value = await this.evaluateWithDebugger( + send, + input.expression, + input.returnByValue ?? true, + input.awaitPromise ?? true, + ); + const serialized = JSON.stringify(value); + if ( + serialized !== undefined && + Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES + ) { + throw automationError( + "PreviewAutomationResultTooLargeError", + `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, + { maximumBytes: MAX_EVALUATION_BYTES }, + ); + } + return value; + }); + } + + async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise { + const wc = this.requireWebContents(tabId); + const timeoutMs = input.timeoutMs ?? 15_000; + await this.withControlSession(tabId, wc, "waitFor", async (send) => { + await send("Runtime.enable"); + const locator = this.automationLocator(input); + if (locator) await this.ensurePlaywrightInjected(send); + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + const result = await this.evaluateWithDebugger< + { matched: boolean } | { invalidSelector: true; message: string } + >( + send, + `(() => { + try { + const selectorMatched = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, false) !== null; })()` : "true"}; + const textMatched = ${ + input.text + ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})` + : "true" + }; + const urlMatched = ${ + input.urlIncludes + ? `location.href.includes(${JSON.stringify(input.urlIncludes)})` + : "true" + }; + return { matched: selectorMatched && textMatched && urlMatched }; + } catch (error) { + return { invalidSelector: true, message: String(error) }; + } + })()`, + true, + ); + if ("invalidSelector" in result) { + throw automationError("PreviewAutomationInvalidSelectorError", result.message, { + selector: input.selector ?? "", + }); + } + if (result.matched) return; + await sleep(100); + } + throw automationError( + "PreviewAutomationTimeoutError", + `Preview condition did not match within ${timeoutMs}ms.`, + ); + }); + } + + private applyZoom(tabId: string, transform: (current: number) => number): void { + const tab = this.tabs.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()) { + wc.setZoomFactor(next); + } + } + this.update(tabId, { zoomFactor: next }); + } + + private async withControlSession( + tabId: string, + wc: Electron.WebContents, + action: string, + use: ( + send: (method: string, commandParams?: Record) => Promise, + ) => Promise, + ): Promise { + const actionEvent: PreviewAutomationActionEvent = { + id: `browser-action-${Date.now().toString(36)}-${(this.actionSequence++).toString(36)}`, + action, + status: "running", + startedAt: new Date().toISOString(), + }; + this.pushAction(tabId, actionEvent); + const epoch = this.controlEpoch.get(tabId) ?? 0; + const control = await this.ensureControlSession(wc); + let resolveTail: () => void = () => undefined; + const previous = control.tail; + control.tail = new Promise((resolve) => { + resolveTail = resolve; + }); + await previous; + this.update(tabId, { controller: "agent" }); + try { + const send = async (method: string, commandParams?: Record) => { + if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) { + throw automationError( + "PreviewAutomationControlInterruptedError", + "Browser control was interrupted by human input.", + ); + } + const result = await wc.debugger.sendCommand(method, commandParams); + if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) { + throw automationError( + "PreviewAutomationControlInterruptedError", + "Browser control was interrupted by human input.", + ); + } + return result; + }; + const result = await use(send); + this.replaceAction(tabId, { + ...actionEvent, + status: "succeeded", + completedAt: new Date().toISOString(), + }); + return result; + } catch (cause) { + const interrupted = + cause instanceof Error && cause.name === "PreviewAutomationControlInterruptedError"; + this.replaceAction(tabId, { + ...actionEvent, + status: interrupted ? "interrupted" : "failed", + completedAt: new Date().toISOString(), + error: cause instanceof Error ? cause.message : String(cause), + }); + if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause; + throw automationError( + "PreviewAutomationExecutionError", + cause instanceof Error ? cause.message : String(cause), + { tabId, cause }, + ); + } finally { + if (this.tabs.has(tabId)) this.update(tabId, { controller: "none" }); + resolveTail(); + } + } + + private pushAction(tabId: string, event: PreviewAutomationActionEvent): void { + const timeline = this.actionTimeline.get(tabId) ?? []; + timeline.push(event); + if (timeline.length > 200) timeline.splice(0, timeline.length - 200); + this.actionTimeline.set(tabId, timeline); + } + + private replaceAction(tabId: string, event: PreviewAutomationActionEvent): void { + const timeline = this.actionTimeline.get(tabId); + if (!timeline) return; + const index = timeline.findIndex((candidate) => candidate.id === event.id); + if (index >= 0) timeline[index] = event; + } + + private async ensureControlSession(wc: Electron.WebContents): Promise { + const existing = this.controlSessions.get(wc.id); + if (existing) { + await existing.initialized; + return existing; + } + if (wc.isDevToolsOpened()) { + throw automationError( + "PreviewAutomationExecutionError", + "Close preview DevTools before using agent browser control.", + ); + } + if (wc.debugger.isAttached()) { + throw automationError( + "PreviewAutomationExecutionError", + "Preview control cannot attach because another debugger owns this page.", + ); + } + const diagnostics: BrowserDiagnostics = { + consoleEntries: [], + networkEntries: [], + requests: new Map(), + }; + this.diagnostics.set(wc.id, diagnostics); + const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { + if (method === "Page.screencastFrame") { + const frame = params; + const sessionId = frame["sessionId"]; + if (typeof sessionId === "number") { + void wc.debugger + .sendCommand("Page.screencastFrameAck", { sessionId }) + .catch(() => undefined); + } + const tabId = this.tabIdForWebContents(wc.id); + const metadata = + typeof frame["metadata"] === "object" && frame["metadata"] !== null + ? (frame["metadata"] as Record) + : {}; + if (tabId && typeof frame["data"] === "string") { + const payload: DesktopPreviewRecordingFrame = { + tabId, + data: frame["data"], + width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, + height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, + receivedAt: new Date().toISOString(), + }; + for (const listener of this.recordingFrameListeners) listener(payload); + } + } + this.captureDiagnosticMessage(diagnostics, method, params); + }; + const control: BrowserControlSession = { + webContentsId: wc.id, + tail: Promise.resolve(), + initialized: Promise.resolve(), + onMessage, + }; + wc.debugger.on("message", onMessage); + control.initialized = (async () => { + wc.debugger.attach("1.3"); + await Promise.all([ + wc.debugger.sendCommand("Runtime.enable"), + wc.debugger.sendCommand("Accessibility.enable"), + wc.debugger.sendCommand("Network.enable"), + wc.debugger.sendCommand("Log.enable"), + ]); + })(); + this.controlSessions.set(wc.id, control); + try { + await control.initialized; + return control; + } catch (cause) { + this.controlSessions.delete(wc.id); + throw cause; + } + } + + private detachControlSession(webContentsId: number): void { + const control = this.controlSessions.get(webContentsId); + this.controlSessions.delete(webContentsId); + this.diagnostics.delete(webContentsId); + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) return; + if (control) { + wc.debugger.off("message", control.onMessage); + } + if (!wc.debugger.isAttached()) return; + try { + wc.debugger.detach(); + } catch { + // Target teardown can race detachment. + } + } + + private tabIdForWebContents(webContentsId: number): string | null { + for (const [tabId, tab] of this.tabs) { + if (tab.webContentsId === webContentsId) return tabId; + } + return null; + } + + private captureDiagnosticMessage( + diagnostics: BrowserDiagnostics, + method: string, + params: Record, + ): void { + const timestamp = new Date().toISOString(); + if (method === "Runtime.consoleAPICalled") { + const args = Array.isArray(params["args"]) ? params["args"] : []; + const text = args + .map((arg) => { + if (typeof arg !== "object" || arg === null) return String(arg); + const value = arg as Record; + return String(value["value"] ?? value["description"] ?? ""); + }) + .join(" "); + this.pushBounded(diagnostics.consoleEntries, { + level: typeof params["type"] === "string" ? params["type"] : "log", + text, + timestamp, + source: "console", + }); + return; + } + if (method === "Runtime.exceptionThrown") { + const details = + typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null + ? (params["exceptionDetails"] as Record) + : {}; + this.pushBounded(diagnostics.consoleEntries, { + level: "error", + text: String(details["text"] ?? "Uncaught exception"), + timestamp, + source: "exception", + }); + return; + } + if (method === "Log.entryAdded") { + const entry = + typeof params["entry"] === "object" && params["entry"] !== null + ? (params["entry"] as Record) + : {}; + this.pushBounded(diagnostics.consoleEntries, { + level: typeof entry["level"] === "string" ? entry["level"] : "info", + text: String(entry["text"] ?? ""), + timestamp, + source: typeof entry["source"] === "string" ? entry["source"] : "log", + }); + return; + } + const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null; + if (method === "Network.requestWillBeSent" && requestId) { + const request = + typeof params["request"] === "object" && params["request"] !== null + ? (params["request"] as Record) + : {}; + diagnostics.requests.set(requestId, { + url: String(request["url"] ?? ""), + method: String(request["method"] ?? "GET"), + }); + return; + } + if (method === "Network.responseReceived" && requestId) { + const request = diagnostics.requests.get(requestId); + const response = + typeof params["response"] === "object" && params["response"] !== null + ? (params["response"] as Record) + : {}; + const status = typeof response["status"] === "number" ? response["status"] : null; + if (request && status !== null && status >= 400) { + this.pushBounded(diagnostics.networkEntries, { + ...request, + status, + failed: true, + timestamp, + }); + } + return; + } + if (method === "Network.loadingFailed" && requestId) { + const request = diagnostics.requests.get(requestId); + if (request) { + this.pushBounded(diagnostics.networkEntries, { + ...request, + status: null, + failed: true, + errorText: String(params["errorText"] ?? "Network request failed"), + timestamp, + }); + } + diagnostics.requests.delete(requestId); + return; + } + if (method === "Network.loadingFinished" && requestId) diagnostics.requests.delete(requestId); + } + + private pushBounded(buffer: A[], entry: A): void { + buffer.push(entry); + if (buffer.length > DIAGNOSTIC_BUFFER_LIMIT) { + buffer.splice(0, buffer.length - DIAGNOSTIC_BUFFER_LIMIT); + } + } + + private async evaluateWithDebugger( + send: (method: string, commandParams?: Record) => Promise, + expression: string, + returnByValue: boolean, + awaitPromise = true, + ): Promise { + const response = (await send("Runtime.evaluate", { + expression, + awaitPromise, + returnByValue, + userGesture: true, + })) as CdpEvaluationResult; + if (response.exceptionDetails) { + throw automationError( + "PreviewAutomationExecutionError", + response.exceptionDetails.exception?.description ?? + response.exceptionDetails.text ?? + "JavaScript evaluation failed.", + ); + } + return response.result?.value as A; + } + + private automationLocator(input: { + readonly selector?: string | undefined; + readonly locator?: string | undefined; + }): string | null { + if (input.locator) return input.locator; + if (input.selector) return `css=${input.selector}`; + return null; + } + + private async ensurePlaywrightInjected( + send: (method: string, commandParams?: Record) => Promise, + ): Promise { + const installed = await this.evaluateWithDebugger( + send, + "Boolean(globalThis.__t3PlaywrightInjected)", + true, + ); + if (installed) return; + const expression = await playwrightInjectedRuntimeInstallExpression(); + await this.evaluateWithDebugger(send, expression, true); + } + + onStateChange(listener: Listener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + onPointerEvent(listener: PointerEventListener): () => void { + this.pointerEventListeners.add(listener); + return () => { + this.pointerEventListeners.delete(listener); + }; + } + + private emitPointerEvent(event: DesktopPreviewPointerEvent): void { + for (const listener of this.pointerEventListeners) listener(event); + } + + private expectAgentInput(tabId: string, signal: PreviewInputSignal): void { + const now = Date.now(); + const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter( + (expected) => expected.expiresAt > now, + ); + pending.push({ signal, expiresAt: now + 1_000 }); + this.expectedAgentInputs.set(tabId, pending); + } + + private consumeExpectedAgentInput(tabId: string, signal: PreviewInputSignal): boolean { + const now = Date.now(); + const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter( + (expected) => expected.expiresAt > now, + ); + const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal)); + if (index < 0) { + if (pending.length === 0) this.expectedAgentInputs.delete(tabId); + else this.expectedAgentInputs.set(tabId, pending); + return false; + } + pending.splice(index, 1); + if (pending.length === 0) this.expectedAgentInputs.delete(tabId); + else this.expectedAgentInputs.set(tabId, pending); + return true; + } + + destroy(): void { + for (const tabId of Array.from(this.tabs.keys())) { + this.closeTab(tabId); + } + this.listeners.clear(); + this.expectedAgentInputs.clear(); + this.pointerEventListeners.clear(); + this.recordingFrameListeners.clear(); + } + + private attachListeners(tabId: string, wc: Electron.WebContents): void { + const sync = () => { + if (wc.isDestroyed()) return; + this.update(tabId, { + navStatus: this.computeNavStatus(wc), + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + }); + }; + const failed = (_event: Event, code: number, description: string): void => { + // -3 = ABORTED (user navigated away mid-load); ignore. + if (code === -3) return; + this.update(tabId, { + navStatus: { + kind: "LoadFailed", + url: wc.getURL(), + title: wc.getTitle(), + code, + description, + }, + }); + }; + const humanInput = (_event: unknown, rawSignal?: unknown): void => { + if (isPreviewInputSignal(rawSignal) && this.consumeExpectedAgentInput(tabId, rawSignal)) { + return; + } + this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1); + this.update(tabId, { controller: "human" }); + void sleep(750).then(() => { + if (this.tabs.get(tabId)?.controller === "human") { + this.update(tabId, { controller: "none" }); + } + }); + }; + + wc.on("did-navigate", sync); + wc.on("did-navigate-in-page", sync); + wc.on("page-title-updated", sync); + wc.on("did-start-loading", sync); + wc.on("did-stop-loading", sync); + wc.on("did-fail-load", failed as never); + wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); + + // Keep external links inside the same view (matches ami's policy). + wc.setWindowOpenHandler(({ url }) => { + void wc.loadURL(url); + return { action: "deny" }; + }); + + // Forward app-level shortcuts to the main window so mod+shift+J etc. + // still toggles the preview panel even when the webview has focus. + const beforeInput = (event: Electron.Event, input: Electron.Input): void => { + if (this.isAppShortcut(input) && this.mainWindow && !this.mainWindow.isDestroyed()) { + event.preventDefault(); + this.mainWindow.webContents.sendInputEvent({ + type: "keyDown", + keyCode: input.key, + modifiers: [ + ...(input.meta ? (["meta"] as const) : []), + ...(input.shift ? (["shift"] as const) : []), + ...(input.control ? (["control"] as const) : []), + ...(input.alt ? (["alt"] as const) : []), + ], + }); + } + }; + wc.on("before-input-event", beforeInput); + + this.attached.set(wc.id, { navigate: sync, failed, humanInput, beforeInput }); + } + + private detachListeners(webContentsId: number): void { + const handlers = this.attached.get(webContentsId); + if (!handlers) return; + this.attached.delete(webContentsId); + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) return; + wc.off("did-navigate", handlers.navigate); + wc.off("did-navigate-in-page", handlers.navigate); + wc.off("page-title-updated", handlers.navigate); + wc.off("did-start-loading", handlers.navigate); + wc.off("did-stop-loading", handlers.navigate); + wc.off("did-fail-load", handlers.failed as never); + wc.off("before-input-event", handlers.beforeInput); + wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput); + } + + private isAppShortcut(input: Electron.Input): boolean { + if (input.type !== "keyDown") return false; + return APP_FORWARDED_SHORTCUTS.some( + (shortcut) => + shortcut.key.toLowerCase() === input.key.toLowerCase() && + shortcut.meta === input.meta && + shortcut.shift === input.shift && + shortcut.control === input.control, + ); + } + + private computeNavStatus(wc: Electron.WebContents): PreviewNavStatus { + const url = wc.getURL(); + const title = wc.getTitle(); + if (url === "" || url === "about:blank") return { kind: "Idle" }; + if (wc.isLoading()) return { kind: "Loading", url, title }; + return { kind: "Success", url, title }; + } + + private requireWebContents(tabId: string): Electron.WebContents { + const tab = this.tabs.get(tabId); + if (!tab) throw new PreviewTabNotFoundError(tabId); + if (tab.webContentsId == null) throw new PreviewWebviewNotInitializedError(tabId); + const wc = webContents.fromId(tab.webContentsId); + if (!wc) throw new PreviewWebContentsNotFoundError(tabId, tab.webContentsId); + return wc; + } + + private update(tabId: string, patch: Partial): void { + const current = this.tabs.get(tabId); + if (!current) return; + const next: PreviewTabState = { + ...current, + ...patch, + updatedAt: new Date().toISOString(), + }; + this.tabs.set(tabId, next); + this.emit(tabId, next); + } + + private emit(tabId: string, state: PreviewTabState): void { + for (const listener of this.listeners) { + try { + listener(tabId, state); + } catch { + // listener errors must not crash the manager + } + } + } + + private normalizeUrl(input: string): string { + // Surface the shared error directly so the IPC caller (and any future + // desktop-side telemetry) gets the `detail` field for free. Defining a + // bespoke desktop class would just lose information. + return normalizePreviewUrl(input); + } +} + +export class PreviewTabNotFoundError extends Error { + readonly tabId: string; + constructor(tabId: string) { + super(`Preview tab not found: ${tabId}`); + this.name = "PreviewTabNotFoundError"; + this.tabId = tabId; + } +} + +export class PreviewWebContentsNotFoundError extends Error { + readonly tabId: string; + readonly webContentsId: number; + constructor(tabId: string, webContentsId: number) { + super(`WebContents ${webContentsId} not found for preview tab ${tabId}`); + this.name = "PreviewWebContentsNotFoundError"; + this.tabId = tabId; + this.webContentsId = webContentsId; + } +} + +export class PreviewWebviewNotInitializedError extends Error { + readonly tabId: string; + constructor(tabId: string) { + super(`Preview tab "${tabId}" has no webview registered`); + this.name = "PreviewWebviewNotInitializedError"; + this.tabId = tabId; + } +} + +export class PreviewManagerError extends Data.TaggedError("PreviewManagerError")<{ + readonly operation: string; + readonly cause: unknown; +}> { + override get message() { + return `Desktop preview operation failed: ${this.operation}`; + } +} + +export interface PreviewManagerShape { + readonly setMainWindow: (window: BrowserWindow) => Effect.Effect; + readonly getBrowserSession: (scope?: string) => Effect.Effect; + readonly isBrowserPartition: (partition: string) => boolean; + readonly createTab: (tabId: string) => Effect.Effect; + readonly closeTab: (tabId: string) => Effect.Effect; + readonly registerWebview: ( + tabId: string, + webContentsId: number, + ) => 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 hardReload: (tabId: string) => Effect.Effect; + readonly openDevTools: (tabId: string) => Effect.Effect; + readonly clearCookies: () => Effect.Effect; + readonly clearCache: () => Effect.Effect; + readonly getBrowserPartition: (scope?: string) => string; + readonly setAnnotationTheme: ( + theme: DesktopPreviewAnnotationTheme, + ) => Effect.Effect; + readonly pickElement: ( + tabId: string, + ) => 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 startRecording: (tabId: string) => Effect.Effect; + readonly stopRecording: (tabId: string) => Effect.Effect; + readonly saveRecording: ( + tabId: string, + mimeType: string, + data: Uint8Array, + ) => Effect.Effect; + readonly automationStatus: ( + tabId: string, + ) => Effect.Effect; + readonly automationSnapshot: ( + tabId: string, + ) => Effect.Effect; + readonly automationClick: ( + tabId: string, + input: PreviewAutomationClickInput, + ) => Effect.Effect; + readonly automationType: ( + tabId: string, + input: PreviewAutomationTypeInput, + ) => Effect.Effect; + readonly automationPress: ( + tabId: string, + input: PreviewAutomationPressInput, + ) => Effect.Effect; + readonly automationScroll: ( + tabId: string, + input: PreviewAutomationScrollInput, + ) => Effect.Effect; + readonly automationEvaluate: ( + tabId: string, + input: PreviewAutomationEvaluateInput, + ) => Effect.Effect; + readonly automationWaitFor: ( + tabId: string, + input: PreviewAutomationWaitForInput, + ) => Effect.Effect; + readonly subscribeStateChanges: (listener: Listener) => Effect.Effect; + readonly subscribePointerEvents: ( + listener: PointerEventListener, + ) => Effect.Effect; + readonly subscribeRecordingFrames: ( + listener: RecordingFrameListener, + ) => Effect.Effect; +} + +export class PreviewManager extends Context.Service()( + "@t3tools/desktop/preview/Manager/PreviewManager", +) {} + +const make = Effect.fn("PreviewManager.make")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const browserSession = yield* BrowserSession.BrowserSession; + const manager = new PreviewViewManager(); + manager.configureArtifactDirectory(environment.browserArtifactsDir); + yield* Effect.addFinalizer(() => Effect.sync(() => manager.destroy())); + + const attempt = ( + operation: string, + evaluate: () => A, + ): Effect.Effect => + Effect.try({ + try: evaluate, + catch: (cause) => new PreviewManagerError({ operation, cause }), + }); + const attemptPromise = ( + operation: string, + evaluate: () => Promise, + ): Effect.Effect => + Effect.tryPromise({ + try: evaluate, + catch: (cause) => new PreviewManagerError({ operation, cause }), + }); + const browserSessionEffect = ( + operation: string, + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe(Effect.mapError((cause) => new PreviewManagerError({ operation, cause }))); + + return PreviewManager.of({ + setMainWindow: Effect.fn("PreviewManager.setMainWindow")(function* (window) { + yield* attempt("setMainWindow", () => manager.setMainWindow(window)); + }), + getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) { + return yield* browserSessionEffect("getBrowserSession", browserSession.getSession(scope)); + }), + isBrowserPartition: browserSession.isPartition, + createTab: Effect.fn("PreviewManager.createTab")(function* (tabId) { + return yield* attempt("createTab", () => manager.createTab(tabId)); + }), + closeTab: Effect.fn("PreviewManager.closeTab")(function* (tabId) { + yield* attempt("closeTab", () => manager.closeTab(tabId)); + }), + registerWebview: Effect.fn("PreviewManager.registerWebview")(function* (tabId, webContentsId) { + yield* attempt("registerWebview", () => manager.registerWebview(tabId, webContentsId)); + }), + navigate: Effect.fn("PreviewManager.navigate")(function* (tabId, url) { + yield* attemptPromise("navigate", () => manager.navigate(tabId, url)); + }), + goBack: Effect.fn("PreviewManager.goBack")(function* (tabId) { + yield* attempt("goBack", () => manager.goBack(tabId)); + }), + goForward: Effect.fn("PreviewManager.goForward")(function* (tabId) { + yield* attempt("goForward", () => manager.goForward(tabId)); + }), + refresh: Effect.fn("PreviewManager.refresh")(function* (tabId) { + yield* attempt("refresh", () => manager.refresh(tabId)); + }), + zoomIn: Effect.fn("PreviewManager.zoomIn")(function* (tabId) { + yield* attempt("zoomIn", () => manager.zoomIn(tabId)); + }), + zoomOut: Effect.fn("PreviewManager.zoomOut")(function* (tabId) { + yield* attempt("zoomOut", () => manager.zoomOut(tabId)); + }), + resetZoom: Effect.fn("PreviewManager.resetZoom")(function* (tabId) { + yield* attempt("resetZoom", () => manager.resetZoom(tabId)); + }), + hardReload: Effect.fn("PreviewManager.hardReload")(function* (tabId) { + yield* attempt("hardReload", () => manager.hardReload(tabId)); + }), + openDevTools: Effect.fn("PreviewManager.openDevTools")(function* (tabId) { + yield* attempt("openDevTools", () => manager.openDevTools(tabId)); + }), + clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { + yield* browserSessionEffect("clearCookies", browserSession.clearCookies()); + }), + clearCache: Effect.fn("PreviewManager.clearCache")(function* () { + yield* browserSessionEffect("clearCache", browserSession.clearCache()); + }), + getBrowserPartition: browserSession.getPartition, + setAnnotationTheme: Effect.fn("PreviewManager.setAnnotationTheme")(function* (theme) { + yield* attempt("setAnnotationTheme", () => manager.setAnnotationTheme(theme)); + }), + pickElement: Effect.fn("PreviewManager.pickElement")(function* (tabId) { + return yield* attemptPromise("pickElement", () => manager.pickElement(tabId)); + }), + cancelPickElement: Effect.fn("PreviewManager.cancelPickElement")(function* (tabId) { + yield* attempt("cancelPickElement", () => manager.cancelPickElement(tabId)); + }), + captureScreenshot: Effect.fn("PreviewManager.captureScreenshot")(function* (tabId) { + return yield* attemptPromise("captureScreenshot", () => manager.captureScreenshot(tabId)); + }), + revealArtifact: Effect.fn("PreviewManager.revealArtifact")(function* (path) { + yield* attempt("revealArtifact", () => manager.revealArtifact(path)); + }), + copyArtifactToClipboard: Effect.fn("PreviewManager.copyArtifactToClipboard")(function* (path) { + yield* attempt("copyArtifactToClipboard", () => manager.copyArtifactToClipboard(path)); + }), + startRecording: Effect.fn("PreviewManager.startRecording")(function* (tabId) { + yield* attemptPromise("startRecording", () => manager.startRecording(tabId)); + }), + stopRecording: Effect.fn("PreviewManager.stopRecording")(function* (tabId) { + yield* attemptPromise("stopRecording", () => manager.stopRecording(tabId)); + }), + saveRecording: Effect.fn("PreviewManager.saveRecording")(function* (tabId, mimeType, data) { + return yield* attemptPromise("saveRecording", () => + manager.saveRecording(tabId, mimeType, data), + ); + }), + automationStatus: Effect.fn("PreviewManager.automationStatus")(function* (tabId) { + return yield* attempt("automationStatus", () => manager.automationStatus(tabId)); + }), + automationSnapshot: Effect.fn("PreviewManager.automationSnapshot")(function* (tabId) { + return yield* attemptPromise("automationSnapshot", () => manager.automationSnapshot(tabId)); + }), + automationClick: Effect.fn("PreviewManager.automationClick")(function* (tabId, input) { + yield* attemptPromise("automationClick", () => manager.automationClick(tabId, input)); + }), + automationType: Effect.fn("PreviewManager.automationType")(function* (tabId, input) { + yield* attemptPromise("automationType", () => manager.automationType(tabId, input)); + }), + automationPress: Effect.fn("PreviewManager.automationPress")(function* (tabId, input) { + yield* attemptPromise("automationPress", () => manager.automationPress(tabId, input)); + }), + automationScroll: Effect.fn("PreviewManager.automationScroll")(function* (tabId, input) { + yield* attemptPromise("automationScroll", () => manager.automationScroll(tabId, input)); + }), + automationEvaluate: Effect.fn("PreviewManager.automationEvaluate")(function* (tabId, input) { + return yield* attemptPromise("automationEvaluate", () => + manager.automationEvaluate(tabId, input), + ); + }), + automationWaitFor: Effect.fn("PreviewManager.automationWaitFor")(function* (tabId, input) { + yield* attemptPromise("automationWaitFor", () => manager.automationWaitFor(tabId, input)); + }), + subscribeStateChanges: (listener) => + Effect.acquireRelease( + Effect.sync(() => manager.onStateChange(listener)), + (unsubscribe) => Effect.sync(unsubscribe), + ).pipe(Effect.asVoid), + subscribePointerEvents: (listener) => + Effect.acquireRelease( + Effect.sync(() => manager.onPointerEvent(listener)), + (unsubscribe) => Effect.sync(unsubscribe), + ).pipe(Effect.asVoid), + subscribeRecordingFrames: (listener) => + Effect.acquireRelease( + Effect.sync(() => manager.onRecordingFrame(listener)), + (unsubscribe) => Effect.sync(unsubscribe), + ).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(PreviewManager, make()); + +/** Exposed for tests. */ +export const __testing = { + PreviewViewManager, +}; diff --git a/apps/desktop/src/preview/PickLabelPosition.ts b/apps/desktop/src/preview/PickLabelPosition.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf7f3c811f882d85d7034ecab1f2499cd2c87a62 --- /dev/null +++ b/apps/desktop/src/preview/PickLabelPosition.ts @@ -0,0 +1,46 @@ +/** + * Pure clamp/flip math for the floating label that follows the cursor while + * the user is picking an element in the in-app browser. Lives in its own + * electron-free module so the geometry can be unit-tested without spinning + * up an Electron preload context (`PickPreload.ts` itself imports + * `electron` and `react-grab/primitives`, which can't load under vitest). + * + * - Horizontally pins the label to `targetLeft`, clamped into + * `[VIEWPORT_MARGIN, viewportWidth - labelWidth - VIEWPORT_MARGIN]`. + * - Vertically prefers above the target. If the label would overflow the + * top, flips below; if THAT also overflows the bottom, pins to the + * bottom margin (better to overlap the highlight than disappear). + */ + +/** Distance in CSS pixels between the highlight and the floating label. */ +export const LABEL_GAP = 4; +/** Minimum padding the label keeps from any viewport edge. */ +export const VIEWPORT_MARGIN = 4; + +export function computeLabelPosition(input: { + targetLeft: number; + targetTop: number; + targetBottom: number; + labelWidth: number; + labelHeight: number; + viewportWidth: number; + viewportHeight: number; +}): { x: number; y: number } { + const { targetLeft, targetTop, targetBottom, labelWidth, labelHeight } = input; + const { viewportWidth, viewportHeight } = input; + + let x = targetLeft; + const maxX = viewportWidth - labelWidth - VIEWPORT_MARGIN; + if (x > maxX) x = maxX; + if (x < VIEWPORT_MARGIN) x = VIEWPORT_MARGIN; + + let y = targetTop - labelHeight - LABEL_GAP; + if (y < VIEWPORT_MARGIN) { + y = targetBottom + LABEL_GAP; + if (y + labelHeight > viewportHeight - VIEWPORT_MARGIN) { + y = Math.max(VIEWPORT_MARGIN, viewportHeight - labelHeight - VIEWPORT_MARGIN); + } + } + + return { x, y }; +} diff --git a/apps/desktop/src/preview/PickPreload.test.ts b/apps/desktop/src/preview/PickPreload.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5696fe50812e98756596526d1e892522b4549649 --- /dev/null +++ b/apps/desktop/src/preview/PickPreload.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { computeLabelPosition } from "./PickLabelPosition.ts"; + +const VIEWPORT = { viewportWidth: 1280, viewportHeight: 800 }; + +describe("computeLabelPosition", () => { + it("anchors to the element's top-left when there's room above and to the right", () => { + const { x, y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 200, + targetTop: 200, + targetBottom: 240, + labelWidth: 120, + labelHeight: 18, + }); + expect(x).toBe(200); + // 200 (top) - 18 (height) - 4 (gap) + expect(y).toBe(200 - 18 - 4); + }); + + it("clamps left edge so the label stays inside the viewport", () => { + const { x } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: -50, + targetTop: 200, + targetBottom: 240, + labelWidth: 120, + labelHeight: 18, + }); + expect(x).toBe(4); + }); + + it("clamps right edge when the label would overflow the viewport (the bug we shipped)", () => { + const { x } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 1240, + targetTop: 200, + targetBottom: 240, + labelWidth: 200, + labelHeight: 18, + }); + // viewportWidth (1280) - labelWidth (200) - margin (4) = 1076 + expect(x).toBe(1076); + }); + + it("flips the label below the element when there's no room above", () => { + const { y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 200, + targetTop: 4, + targetBottom: 44, + labelWidth: 120, + labelHeight: 18, + }); + // labelY = 4 - 18 - 4 = -18 → flip → 44 + 4 = 48 + expect(y).toBe(48); + }); + + it("pins to the bottom margin when the element fills the viewport (no room above OR below)", () => { + const { y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: 200, + targetTop: 0, + targetBottom: 800, + labelWidth: 120, + labelHeight: 18, + }); + // Above overflows top → flip below = 800 + 4 = 804 → also overflows + // bottom → pin to viewportHeight - labelHeight - margin = 778. + expect(y).toBe(800 - 18 - 4); + }); + + it("never returns a negative coordinate", () => { + const { x, y } = computeLabelPosition({ + ...VIEWPORT, + targetLeft: -1000, + targetTop: -1000, + targetBottom: -900, + labelWidth: 5000, + labelHeight: 5000, + }); + expect(x).toBeGreaterThanOrEqual(0); + expect(y).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts new file mode 100644 index 0000000000000000000000000000000000000000..e8c31a8e1e5b5bdf44808bac805a134dbf15466b --- /dev/null +++ b/apps/desktop/src/preview/PickPreload.ts @@ -0,0 +1,1263 @@ +// @effect-diagnostics globalDate:off +import { ipcRenderer } from "electron"; +import { getElementContext } from "react-grab/primitives"; +import type { + DesktopPreviewAnnotationTheme, + PickedElementPayload, + PickedElementStackFrame, + PreviewAnnotationPayload, + PreviewAnnotationPoint, + PreviewAnnotationRect, + PreviewAnnotationRegionTarget, + PreviewAnnotationStrokeTarget, + PreviewAnnotationStyleChange, +} from "@t3tools/contracts"; + +import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; +import { + ANNOTATION_CAPTURED_CHANNEL, + ANNOTATION_THEME_CHANNEL, + CANCEL_PICK_CHANNEL, + ELEMENT_PICKED_CHANNEL, + HUMAN_INPUT_CHANNEL, + START_PICK_CHANNEL, +} from "./GuestProtocol.ts"; +const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; +const Z_INDEX_OVERLAY = 2147483646; +const PRIMARY = "var(--t3-primary)"; +const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; +const MAX_MARQUEE_ELEMENTS = 20; +const CONTENT_LAYER_Z_INDEX = 1; +const CHROME_LAYER_Z_INDEX = 10; + +type AnnotationTool = "select" | "marquee" | "draw" | "erase"; + +interface SelectedElement { + id: string; + element: Element; + outline: HTMLDivElement; + label: HTMLDivElement; + baselineStyles: Map; +} + +interface AnnotationSession { + teardown: (notifyMain: boolean) => void; + applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; +} + +let activeSession: AnnotationSession | null = null; +let idSequence = 0; +let annotationTheme: DesktopPreviewAnnotationTheme | null = null; + +const applyAnnotationTheme = ( + host: HTMLElement, + theme: DesktopPreviewAnnotationTheme | null, +): void => { + if (!theme) return; + host.style.colorScheme = theme.colorScheme; + const variables = { + "--t3-radius": theme.radius, + "--t3-background": theme.background, + "--t3-foreground": theme.foreground, + "--t3-popover": theme.popover, + "--t3-popover-foreground": theme.popoverForeground, + "--t3-primary": theme.primary, + "--t3-primary-foreground": theme.primaryForeground, + "--t3-muted": theme.muted, + "--t3-muted-foreground": theme.mutedForeground, + "--t3-accent": theme.accent, + "--t3-accent-foreground": theme.accentForeground, + "--t3-border": theme.border, + "--t3-input": theme.input, + "--t3-ring": theme.ring, + "--t3-font-sans": theme.fontSans, + "--t3-font-mono": theme.fontMono, + }; + for (const [name, value] of Object.entries(variables)) { + host.style.setProperty(name, value); + } +}; + +const reportHumanPointerInput = (event: PointerEvent): void => { + if (!event.isTrusted) return; + ipcRenderer.send(HUMAN_INPUT_CHANNEL, { + kind: "pointer", + x: event.clientX, + y: event.clientY, + button: event.button, + }); +}; + +const reportHumanKeyInput = (event: KeyboardEvent): void => { + if (!event.isTrusted) return; + ipcRenderer.send(HUMAN_INPUT_CHANNEL, { + kind: "key", + key: event.key, + code: event.code, + }); +}; + +window.addEventListener("pointerdown", reportHumanPointerInput, true); +window.addEventListener("keydown", reportHumanKeyInput, true); + +const nextId = (prefix: string): string => { + idSequence += 1; + return `${prefix}_${idSequence.toString(36)}`; +}; + +const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({ + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, +}); + +const normalizeRect = ( + startX: number, + startY: number, + endX: number, + endY: number, +): PreviewAnnotationRect => ({ + x: Math.min(startX, endX), + y: Math.min(startY, endY), + width: Math.abs(endX - startX), + height: Math.abs(endY - startY), +}); + +const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; + +function unionRects( + rects: ReadonlyArray, + padding = 20, +): PreviewAnnotationRect | null { + if (rects.length === 0) return null; + const left = Math.min(...rects.map((rect) => rect.x)); + const top = Math.min(...rects.map((rect) => rect.y)); + const right = Math.max(...rects.map((rect) => rect.x + rect.width)); + const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); + const x = Math.max(0, left - padding); + const y = Math.max(0, top - padding); + const maxWidth = Math.max(1, window.innerWidth - x); + const maxHeight = Math.max(1, window.innerHeight - y); + return { + x, + y, + width: Math.min(maxWidth, right - left + padding * 2), + height: Math.min(maxHeight, bottom - top + padding * 2), + }; +} + +function isAnnotationNode(element: Element): boolean { + return element instanceof Element && element.closest(`[${OVERLAY_ATTRIBUTE}]`) !== null; +} + +function pickFromPoint(clientX: number, clientY: number): Element | null { + for (const candidate of document.elementsFromPoint(clientX, clientY)) { + if (!(candidate instanceof Element)) continue; + if (isAnnotationNode(candidate)) continue; + if (candidate === document.documentElement || candidate === document.body) continue; + return candidate; + } + return null; +} + +function describeRawElement(element: Element): string { + const tag = element.tagName.toLowerCase(); + const id = element.id ? `#${element.id}` : ""; + const classes = + element instanceof HTMLElement && typeof element.className === "string" + ? element.className + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((name) => `.${name}`) + .join("") + : ""; + return `${tag}${id}${classes}`; +} + +function createBox(color: string, fill: string): HTMLDivElement { + const node = document.createElement("div"); + node.setAttribute(OVERLAY_ATTRIBUTE, ""); + node.style.cssText = [ + "position:fixed", + "pointer-events:none", + `border:2px solid ${color}`, + `background:${fill}`, + "border-radius:3px", + "box-sizing:border-box", + "display:none", + `z-index:${CONTENT_LAYER_Z_INDEX}`, + ].join(";"); + return node; +} + +function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { + node.style.display = "block"; + node.style.transform = `translate(${rect.x}px, ${rect.y}px)`; + node.style.width = `${rect.width}px`; + node.style.height = `${rect.height}px`; +} + +function createLabel(): HTMLDivElement { + const label = document.createElement("div"); + label.setAttribute(OVERLAY_ATTRIBUTE, ""); + label.className = + "fixed z-1 max-w-70 overflow-hidden rounded-md bg-primary px-2 py-1 font-sans text-xs font-semibold text-primary-foreground shadow-md"; + label.style.cssText = [ + "position:fixed", + "pointer-events:none", + "white-space:nowrap", + "text-overflow:ellipsis", + `z-index:${CONTENT_LAYER_Z_INDEX}`, + ].join(";"); + return label; +} + +function updateSelectedVisual(target: SelectedElement): void { + if (!target.element.isConnected) { + target.outline.style.display = "none"; + target.label.style.display = "none"; + return; + } + const rect = target.element.getBoundingClientRect(); + positionBox(target.outline, rectFromDomRect(rect)); + target.label.textContent = describeRawElement(target.element); + target.label.style.display = "block"; + target.label.style.transform = `translate(${Math.max(4, rect.left)}px, ${Math.max(4, rect.top - 22)}px)`; +} + +function toStackFrame(frame: { + functionName?: string; + fileName?: string; + lineNumber?: number; + columnNumber?: number; +}): PickedElementStackFrame { + return { + functionName: frame.functionName ?? null, + fileName: frame.fileName ?? null, + lineNumber: frame.lineNumber ?? null, + columnNumber: frame.columnNumber ?? null, + }; +} + +async function captureElement(element: Element): Promise { + try { + const context = await getElementContext(element); + const stack = (context.stack ?? []).map(toStackFrame); + return { + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + tagName: element.tagName.toLowerCase(), + selector: context.selector, + htmlPreview: context.htmlPreview ?? "", + componentName: context.componentName, + source: stack[0] ?? null, + stack, + styles: context.styles ?? "", + pickedAt: new Date().toISOString(), + }; + } catch { + return null; + } +} + +function createButton(label: string, title: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.title = title; + button.className = + "inline-flex h-7 cursor-pointer items-center justify-center rounded-md border border-transparent px-2 font-sans text-xs font-medium text-foreground outline-none hover:bg-accent disabled:pointer-events-none disabled:opacity-60"; + return button; +} + +function styleControl(input: HTMLInputElement | HTMLSelectElement): void { + input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value"); + input.className = + "h-7 min-w-0 w-full appearance-none rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground shadow-xs outline-none"; +} + +function createUnitControl(input: HTMLInputElement): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.style.cssText = "position:relative;min-width:0"; + const unit = document.createElement("span"); + unit.textContent = input.dataset.unit ?? ""; + unit.className = + "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground"; + wrapper.append(input, unit); + return wrapper; +} + +function createField( + labelText: string, + input: HTMLInputElement | HTMLSelectElement, +): HTMLLabelElement { + const label = document.createElement("label"); + label.className = + "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; + const text = document.createElement("span"); + text.textContent = labelText; + styleControl(input); + label.append( + text, + input instanceof HTMLInputElement && input.dataset.unit ? createUnitControl(input) : input, + ); + return label; +} + +function createStyleSection(): HTMLElement { + const section = document.createElement("section"); + section.className = "grid gap-1 border-t border-border py-2"; + return section; +} + +function createUnitInput(unit: string, placeholder = "0"): HTMLInputElement { + const input = document.createElement("input"); + input.type = "number"; + input.placeholder = placeholder; + input.style.paddingRight = "30px"; + input.dataset.unit = unit; + return input; +} + +function pathFromPoints(points: ReadonlyArray): string { + if (points.length === 0) return ""; + if (points.length === 1) return `M ${points[0]!.x} ${points[0]!.y} l 0.01 0.01`; + let path = `M ${points[0]!.x} ${points[0]!.y}`; + for (let index = 1; index < points.length - 1; index += 1) { + const current = points[index]!; + const next = points[index + 1]!; + path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`; + } + const last = points[points.length - 1]!; + path += ` L ${last.x} ${last.y}`; + return path; +} + +function strokeBounds( + points: ReadonlyArray, + width: number, +): PreviewAnnotationRect { + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const padding = width + 3; + const left = Math.min(...xs) - padding; + const top = Math.min(...ys) - padding; + const right = Math.max(...xs) + padding; + const bottom = Math.max(...ys) + padding; + return { x: left, y: top, width: right - left, height: bottom - top }; +} + +function startAnnotation(): void { + activeSession?.teardown(false); + let finished = false; + const host = document.createElement("div"); + host.setAttribute(OVERLAY_ATTRIBUTE, ""); + host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`; + applyAnnotationTheme(host, annotationTheme); + const shadowRoot = host.attachShadow({ mode: "closed" }); + const themeStyle = document.createElement("style"); + themeStyle.textContent = previewAnnotationStyles; + shadowRoot.appendChild(themeStyle); + + const root = document.createElement("div"); + root.setAttribute(OVERLAY_ATTRIBUTE, ""); + root.className = "fixed inset-0 font-sans text-foreground"; + root.style.cssText = "pointer-events:none"; + const cursorStyle = document.createElement("style"); + cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, ""); + cursorStyle.textContent = `html[data-t3code-annotation-tool] body, html[data-t3code-annotation-tool] body * { cursor: crosshair !important; } [${OVERLAY_ATTRIBUTE}], [${OVERLAY_ATTRIBUTE}] * { cursor: default !important; } [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-inner-spin-button, [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-outer-spin-button { appearance:none; margin:0; }`; + document.documentElement.appendChild(cursorStyle); + shadowRoot.appendChild(root); + + const hoverOutline = createBox(PRIMARY, PRIMARY_FILL); + const marqueeBox = createBox(PRIMARY, PRIMARY_FILL); + root.append(hoverOutline, marqueeBox); + + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute(OVERLAY_ATTRIBUTE, ""); + svg.setAttribute("width", "100%"); + svg.setAttribute("height", "100%"); + svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); + svg.style.cssText = "position:fixed;inset:0;overflow:visible;pointer-events:none"; + svg.style.zIndex = String(CONTENT_LAYER_Z_INDEX); + root.appendChild(svg); + + const toolbar = document.createElement("div"); + toolbar.setAttribute(OVERLAY_ATTRIBUTE, ""); + toolbar.className = + "pointer-events-auto fixed top-2.5 left-1/2 flex -translate-x-1/2 gap-0.5 rounded-lg border border-border bg-popover/95 p-1 text-popover-foreground shadow-lg backdrop-blur-xl"; + toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX); + root.appendChild(toolbar); + + const editor = document.createElement("div"); + editor.setAttribute(OVERLAY_ATTRIBUTE, ""); + editor.className = + "pointer-events-auto fixed hidden max-h-[calc(100vh-16px)] w-[min(360px,calc(100vw-16px))] flex-col overflow-hidden rounded-xl border border-border bg-popover/96 text-popover-foreground shadow-2xl backdrop-blur-xl"; + editor.style.zIndex = String(CHROME_LAYER_Z_INDEX); + root.appendChild(editor); + + const composerRow = document.createElement("div"); + composerRow.className = "flex items-start gap-2 p-2"; + + const adjust = createButton("", "Expand annotation editor"); + adjust.setAttribute("aria-label", "Expand annotation editor"); + adjust.setAttribute("aria-expanded", "false"); + adjust.className += + " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"; + adjust.innerHTML = + ''; + composerRow.appendChild(adjust); + + const comment = document.createElement("textarea"); + comment.placeholder = "Describe the change…"; + comment.rows = 1; + comment.className = + "min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0"; + composerRow.appendChild(comment); + + const dragHandle = document.createElement("button"); + dragHandle.type = "button"; + dragHandle.textContent = "⠿"; + dragHandle.title = "Drag annotation editor"; + dragHandle.className = + "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground"; + composerRow.appendChild(dragHandle); + + const submit = createButton("Attach", "Attach annotation and screenshot"); + submit.className += + " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; + composerRow.appendChild(submit); + editor.appendChild(composerRow); + + const stylePanel = document.createElement("div"); + stylePanel.className = + "hidden max-h-[min(176px,calc(100vh-180px))] overflow-auto border-t border-border bg-muted/40 px-3"; + editor.appendChild(stylePanel); + + const selected = new Map(); + const regions: PreviewAnnotationRegionTarget[] = []; + const strokes: PreviewAnnotationStrokeTarget[] = []; + const styleChanges = new Map(); + const toolButtons = new Map(); + let tool: AnnotationTool = "select"; + let dragStart: PreviewAnnotationPoint | null = null; + let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; + let pendingCapture = false; + let editorExpanded = false; + let editorWasShown = false; + let editorPosition: { left: number; top: number } | null = null; + let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; + let editorLayoutFrame: number | null = null; + + const resizeComment = (): void => { + const maxHeight = 96; + comment.style.height = "auto"; + const nextHeight = Math.min(comment.scrollHeight, maxHeight); + comment.style.height = `${nextHeight}px`; + comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden"; + queueEditorLayout(); + }; + comment.addEventListener("input", resizeComment); + + const updateStatus = (): void => { + const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0; + editor.style.display = hasTargets ? "flex" : "none"; + submit.disabled = !hasTargets; + submit.style.opacity = hasTargets ? "1" : "0.45"; + adjust.disabled = !hasTargets; + stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none"; + queueEditorLayout(); + if (hasTargets && !editorWasShown) { + editorWasShown = true; + window.setTimeout(() => comment.focus({ preventScroll: true }), 0); + } + }; + + const refreshToolButtons = (): void => { + for (const [candidate, button] of toolButtons) { + const active = candidate === tool; + button.classList.toggle("bg-primary/10", active); + button.classList.toggle("text-primary", active); + button.classList.toggle("text-foreground", !active); + } + if (tool !== "select") hoverOutline.style.display = "none"; + if (tool !== "marquee") marqueeBox.style.display = "none"; + document.documentElement.setAttribute("data-t3code-annotation-tool", tool); + }; + + const removeSelected = (target: SelectedElement): void => { + if (target.element instanceof HTMLElement || target.element instanceof SVGElement) { + for (const [property, baseline] of target.baselineStyles) { + if (baseline) target.element.style.setProperty(property, baseline); + else target.element.style.removeProperty(property); + } + } + selected.delete(target.element); + target.outline.remove(); + target.label.remove(); + for (const [key, change] of styleChanges) { + if (change.targetId === target.id) styleChanges.delete(key); + } + updateStatus(); + }; + + const addSelected = (element: Element): void => { + if (selected.has(element)) return; + const target: SelectedElement = { + id: nextId("element"), + element, + outline: createBox(PRIMARY, PRIMARY_FILL), + label: createLabel(), + baselineStyles: new Map(), + }; + selected.set(element, target); + root.append(target.outline, target.label); + updateSelectedVisual(target); + updateStatus(); + if (editorExpanded) { + stylePanel.style.display = "grid"; + syncStyleControls(); + } + }; + + const toggleSelected = (element: Element, additive: boolean): void => { + const existing = selected.get(element); + if (existing) { + removeSelected(existing); + return; + } + if (!additive) { + for (const target of Array.from(selected.values())) removeSelected(target); + } + addSelected(element); + }; + + const setStyleForSelected = (property: string, value: string): void => { + for (const target of selected.values()) { + if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) + continue; + if (!target.baselineStyles.has(property)) { + target.baselineStyles.set(property, target.element.style.getPropertyValue(property)); + } + const key = `${target.id}:${property}`; + const previousValue = + styleChanges.get(key)?.previousValue ?? + getComputedStyle(target.element).getPropertyValue(property).trim(); + target.element.style.setProperty(property, value, "important"); + styleChanges.set(key, { + targetId: target.id, + selector: null, + property, + previousValue, + value, + }); + updateSelectedVisual(target); + } + }; + + const textSection = createStyleSection(); + const colorsSection = createStyleSection(); + const bordersSection = createStyleSection(); + const sizingSection = createStyleSection(); + stylePanel.append(textSection, colorsSection, bordersSection, sizingSection); + + const fontFamily = document.createElement("select"); + for (const value of ["inherit", "system-ui", "sans-serif", "serif", "monospace"]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + fontFamily.appendChild(option); + } + fontFamily.addEventListener("change", () => setStyleForSelected("font-family", fontFamily.value)); + textSection.appendChild(createField("Font", fontFamily)); + + const fontSize = createUnitInput("px", "16"); + fontSize.min = "1"; + fontSize.max = "300"; + fontSize.addEventListener("input", () => { + if (fontSize.value) setStyleForSelected("font-size", `${fontSize.value}px`); + }); + textSection.appendChild(createField("Font size", fontSize)); + + const fontWeight = document.createElement("select"); + for (const value of ["300", "400", "500", "600", "700", "800", "900"]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + fontWeight.appendChild(option); + } + fontWeight.addEventListener("change", () => setStyleForSelected("font-weight", fontWeight.value)); + textSection.appendChild(createField("Font weight", fontWeight)); + + const lineHeight = document.createElement("input"); + lineHeight.type = "text"; + lineHeight.placeholder = "normal / 1.4"; + lineHeight.addEventListener("change", () => { + if (lineHeight.value.trim()) setStyleForSelected("line-height", lineHeight.value.trim()); + }); + textSection.appendChild(createField("Line height", lineHeight)); + + const createColorRow = ( + labelText: string, + property: string, + section: HTMLElement, + ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => { + const row = document.createElement("label"); + row.className = + "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; + const label = document.createElement("span"); + label.textContent = labelText; + const control = document.createElement("div"); + control.className = + "grid h-7 grid-cols-[22px_minmax(0,1fr)] items-center gap-1 rounded-md border border-input bg-background px-1 shadow-xs"; + const color = document.createElement("input"); + color.type = "color"; + color.setAttribute("aria-label", labelText); + color.style.cssText = + "width:20px;height:20px;padding:0;border:0;border-radius:5px;overflow:hidden;background:transparent;cursor:pointer"; + const text = document.createElement("input"); + text.type = "text"; + text.setAttribute("aria-label", `${labelText} value`); + text.className = + "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none"; + color.addEventListener("input", () => { + text.value = color.value; + setStyleForSelected(property, color.value); + }); + text.addEventListener("change", () => { + const value = text.value.trim(); + if (!value) return; + setStyleForSelected(property, value); + if (/^#[0-9a-f]{6}$/i.test(value)) color.value = value; + }); + control.append(color, text); + row.append(label, control); + section.appendChild(row); + return { row, color, text }; + }; + + const textColor = createColorRow("Text color", "color", colorsSection); + const backgroundColor = createColorRow("Background", "background-color", colorsSection); + + const opacity = document.createElement("input"); + opacity.type = "range"; + opacity.min = "0"; + opacity.max = "1"; + opacity.step = "0.05"; + opacity.value = "1"; + opacity.style.accentColor = PRIMARY; + opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value)); + colorsSection.appendChild(createField("Opacity", opacity)); + + const radius = createUnitInput("px", "0"); + radius.min = "0"; + radius.max = "300"; + radius.addEventListener("input", () => { + if (radius.value) setStyleForSelected("border-radius", `${radius.value}px`); + }); + bordersSection.appendChild(createField("Radius", radius)); + + const borderColor = createColorRow("Border color", "border-color", bordersSection); + + const borderWidth = createUnitInput("px", "0"); + borderWidth.min = "0"; + borderWidth.max = "100"; + borderWidth.addEventListener("input", () => { + if (borderWidth.value) { + setStyleForSelected("border-style", "solid"); + setStyleForSelected("border-width", `${borderWidth.value}px`); + } + }); + bordersSection.appendChild(createField("Border width", borderWidth)); + + const dimensions = document.createElement("div"); + dimensions.style.cssText = + "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center"; + const dimensionLabel = document.createElement("div"); + dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground"; + dimensionLabel.innerHTML = "WidthHeight"; + const dimensionControls = document.createElement("div"); + dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px"; + const widthInput = createUnitInput("px", "auto"); + const heightInput = createUnitInput("px", "auto"); + styleControl(widthInput); + styleControl(heightInput); + const aspectLock = createButton("", "Lock aspect ratio"); + aspectLock.setAttribute("aria-pressed", "true"); + aspectLock.style.cssText += + ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0"; + aspectLock.className += " bg-primary/10 text-primary"; + dimensionControls.append( + createUnitControl(widthInput), + createUnitControl(heightInput), + aspectLock, + ); + dimensions.append(dimensionLabel, dimensionControls); + sizingSection.appendChild(dimensions); + + let aspectLocked = true; + let aspectRatio = 1; + const refreshAspectButton = (): void => { + aspectLock.innerHTML = aspectLocked + ? '' + : ''; + aspectLock.setAttribute("aria-pressed", String(aspectLocked)); + aspectLock.classList.toggle("bg-primary/10", aspectLocked); + aspectLock.classList.toggle("text-primary", aspectLocked); + aspectLock.classList.toggle("bg-muted", !aspectLocked); + aspectLock.classList.toggle("text-muted-foreground", !aspectLocked); + }; + aspectLock.addEventListener("click", () => { + aspectLocked = !aspectLocked; + refreshAspectButton(); + }); + widthInput.addEventListener("input", () => { + const width = Number(widthInput.value); + if (!Number.isFinite(width) || width <= 0) return; + setStyleForSelected("width", `${width}px`); + if (aspectLocked && aspectRatio > 0) { + const height = Math.max(1, Math.round(width / aspectRatio)); + heightInput.value = String(height); + setStyleForSelected("height", `${height}px`); + } + }); + heightInput.addEventListener("input", () => { + const height = Number(heightInput.value); + if (!Number.isFinite(height) || height <= 0) return; + setStyleForSelected("height", `${height}px`); + if (aspectLocked && aspectRatio > 0) { + const width = Math.max(1, Math.round(height * aspectRatio)); + widthInput.value = String(width); + setStyleForSelected("width", `${width}px`); + } + }); + refreshAspectButton(); + + const addSpacingField = ( + label: string, + property: string, + placeholder: string, + ): HTMLInputElement => { + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = placeholder; + input.addEventListener("change", () => { + if (input.value.trim()) setStyleForSelected(property, input.value.trim()); + }); + sizingSection.appendChild(createField(label, input)); + return input; + }; + const padding = addSpacingField("Padding", "padding", "0 0 0 0"); + const margin = addSpacingField("Margin", "margin", "0 0 0 0"); + const gap = addSpacingField("Gap", "gap", "0px"); + + const syncStyleControls = (): void => { + const first = selected.values().next().value as SelectedElement | undefined; + if (!first) return; + const computed = getComputedStyle(first.element); + const rect = first.element.getBoundingClientRect(); + aspectRatio = rect.height > 0 ? rect.width / rect.height : 1; + widthInput.value = String(Math.round(rect.width)); + heightInput.value = String(Math.round(rect.height)); + fontSize.value = String(Math.round(Number.parseFloat(computed.fontSize) || 16)); + fontWeight.value = computed.fontWeight.match(/^[0-9]+$/) ? computed.fontWeight : "400"; + lineHeight.value = computed.lineHeight; + fontFamily.value = Array.from(fontFamily.options).some( + (option) => option.value === computed.fontFamily, + ) + ? computed.fontFamily + : "inherit"; + textColor.text.value = computed.color; + backgroundColor.text.value = computed.backgroundColor; + borderColor.text.value = computed.borderColor; + opacity.value = computed.opacity; + radius.value = String(Math.round(Number.parseFloat(computed.borderRadius) || 0)); + borderWidth.value = String(Math.round(Number.parseFloat(computed.borderWidth) || 0)); + padding.value = computed.padding; + margin.value = computed.margin; + gap.value = computed.gap === "normal" ? "0px" : computed.gap; + }; + + const tools: ReadonlyArray<[AnnotationTool, string, string]> = [ + ["select", "Select", "Select elements (V)"], + ["marquee", "Region", "Draw a region or marquee-select elements (R)"], + ["draw", "Draw", "Draw freehand (D)"], + ["erase", "Erase", "Remove an annotation target (E)"], + ]; + for (const [candidate, label, title] of tools) { + const button = createButton(label, title); + button.className += " h-8 px-2.5 text-sm"; + button.addEventListener("click", () => { + tool = candidate; + refreshToolButtons(); + }); + toolButtons.set(candidate, button); + toolbar.appendChild(button); + } + + const clampEditorPosition = (left: number, top: number): { left: number; top: number } => { + const margin = 8; + const rect = editor.getBoundingClientRect(); + return { + left: Math.min( + Math.max(margin, left), + Math.max(margin, window.innerWidth - rect.width - margin), + ), + top: Math.min( + Math.max(margin, top), + Math.max(margin, window.innerHeight - rect.height - margin), + ), + }; + }; + + const applyEditorPosition = (position: { left: number; top: number }): void => { + const clamped = clampEditorPosition(position.left, position.top); + editor.style.left = `${clamped.left}px`; + editor.style.top = `${clamped.top}px`; + editor.style.right = "auto"; + editor.style.bottom = "auto"; + if (editorExpanded) editorPosition = clamped; + }; + + const getAnnotationBounds = (): PreviewAnnotationRect | null => + unionRects( + [ + ...Array.from(selected.values(), (target) => + rectFromDomRect(target.element.getBoundingClientRect()), + ), + ...regions.map((region) => region.rect), + ...strokes.map((stroke) => stroke.bounds), + ], + 0, + ); + + const positionCompactEditor = (): void => { + const bounds = getAnnotationBounds(); + if (!bounds) return; + const editorRect = editor.getBoundingClientRect(); + const gap = 8; + const candidates = [ + { left: bounds.x + bounds.width + gap, top: bounds.y }, + { left: bounds.x - editorRect.width - gap, top: bounds.y }, + { + left: bounds.x + bounds.width - editorRect.width, + top: bounds.y + bounds.height + gap, + }, + { + left: bounds.x + bounds.width - editorRect.width, + top: bounds.y - editorRect.height - gap, + }, + ]; + const overflow = (position: { left: number; top: number }): number => + Math.max(0, -position.left) + + Math.max(0, -position.top) + + Math.max(0, position.left + editorRect.width - window.innerWidth) + + Math.max(0, position.top + editorRect.height - window.innerHeight); + const best = candidates.reduce((current, candidate) => + overflow(candidate) < overflow(current) ? candidate : current, + ); + applyEditorPosition(best); + }; + + function queueEditorLayout(): void { + if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); + editorLayoutFrame = window.requestAnimationFrame(() => { + editorLayoutFrame = null; + if (editor.style.display === "none") return; + if (editorExpanded && editorPosition) applyEditorPosition(editorPosition); + else positionCompactEditor(); + }); + } + + adjust.addEventListener("click", () => { + if (selected.size === 0) return; + if (!editorExpanded) { + const rect = editor.getBoundingClientRect(); + editorExpanded = true; + editorPosition = { left: rect.left, top: rect.top }; + stylePanel.style.display = selected.size > 0 ? "grid" : "none"; + dragHandle.style.display = "block"; + adjust.setAttribute("aria-expanded", "true"); + adjust.title = "Collapse annotation editor"; + adjust.setAttribute("aria-label", "Collapse annotation editor"); + if (selected.size > 0) syncStyleControls(); + } else { + editorExpanded = false; + editorPosition = null; + stylePanel.style.display = "none"; + dragHandle.style.display = "none"; + adjust.setAttribute("aria-expanded", "false"); + adjust.title = "Expand annotation editor"; + adjust.setAttribute("aria-label", "Expand annotation editor"); + } + queueEditorLayout(); + }); + + const onEditorPointerDown = (event: PointerEvent): void => { + if (event.button !== 0 || !editorExpanded) return; + const rect = editor.getBoundingClientRect(); + editorDrag = { + pointerId: event.pointerId, + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top, + }; + dragHandle.setPointerCapture(event.pointerId); + dragHandle.style.cursor = "grabbing"; + event.preventDefault(); + event.stopPropagation(); + }; + + const onEditorPointerMove = (event: PointerEvent): void => { + if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; + applyEditorPosition({ + left: event.clientX - editorDrag.offsetX, + top: event.clientY - editorDrag.offsetY, + }); + event.preventDefault(); + event.stopPropagation(); + }; + + const onEditorPointerUp = (event: PointerEvent): void => { + if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; + editorDrag = null; + dragHandle.style.cursor = "grab"; + if (dragHandle.hasPointerCapture(event.pointerId)) + dragHandle.releasePointerCapture(event.pointerId); + event.preventDefault(); + event.stopPropagation(); + }; + dragHandle.addEventListener("pointerdown", onEditorPointerDown); + dragHandle.addEventListener("pointermove", onEditorPointerMove); + dragHandle.addEventListener("pointerup", onEditorPointerUp); + dragHandle.addEventListener("pointercancel", onEditorPointerUp); + + const repaint = (): void => { + for (const target of selected.values()) updateSelectedVisual(target); + queueEditorLayout(); + }; + + const removeTargetAtPoint = (x: number, y: number): boolean => { + for (const target of Array.from(selected.values()).toReversed()) { + const rect = target.element.getBoundingClientRect(); + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { + removeSelected(target); + return true; + } + } + const regionIndex = regions.findIndex( + (region) => + x >= region.rect.x && + x <= region.rect.x + region.rect.width && + y >= region.rect.y && + y <= region.rect.y + region.rect.height, + ); + if (regionIndex >= 0) { + const [removed] = regions.splice(regionIndex, 1); + root.querySelector(`[data-region-id="${removed?.id}"]`)?.remove(); + updateStatus(); + return true; + } + const strokeIndex = strokes.findIndex( + (stroke) => + x >= stroke.bounds.x && + x <= stroke.bounds.x + stroke.bounds.width && + y >= stroke.bounds.y && + y <= stroke.bounds.y + stroke.bounds.height, + ); + if (strokeIndex >= 0) { + const [removed] = strokes.splice(strokeIndex, 1); + svg.querySelector(`[data-stroke-id="${removed?.id}"]`)?.remove(); + updateStatus(); + return true; + } + return false; + }; + + const selectElementsInRect = (rect: PreviewAnnotationRect): number => { + const candidates = Array.from(document.querySelectorAll("body *")) + .filter((element) => !isAnnotationNode(element)) + .map((element) => ({ element, rect: element.getBoundingClientRect() })) + .filter(({ rect: candidate }) => { + if (candidate.width < 2 || candidate.height < 2) return false; + return !( + candidate.right < rect.x || + candidate.left > rect.x + rect.width || + candidate.bottom < rect.y || + candidate.top > rect.y + rect.height + ); + }) + .filter(({ element, rect: candidate }) => { + const centerX = candidate.left + candidate.width / 2; + const centerY = candidate.top + candidate.height / 2; + return ( + centerX >= rect.x && + centerX <= rect.x + rect.width && + centerY >= rect.y && + centerY <= rect.y + rect.height && + (element.children.length === 0 || + element instanceof HTMLButtonElement || + element instanceof HTMLAnchorElement || + element.getAttribute("role") === "button") + ); + }) + .sort( + (left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height, + ) + .slice(0, MAX_MARQUEE_ELEMENTS); + for (const candidate of candidates) addSelected(candidate.element); + return candidates.length; + }; + + const clearHoverOutline = (): void => { + hoverOutline.style.display = "none"; + }; + + const onPointerMove = (event: PointerEvent): void => { + if (isAnnotationNode(event.target as Element)) { + clearHoverOutline(); + return; + } + if (tool === "select" && dragStart === null) { + const target = pickFromPoint(event.clientX, event.clientY); + if (target) positionBox(hoverOutline, rectFromDomRect(target.getBoundingClientRect())); + else clearHoverOutline(); + return; + } + clearHoverOutline(); + if (tool === "marquee" && dragStart) { + positionBox( + marqueeBox, + normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY), + ); + return; + } + if (tool === "draw" && activeStroke) { + activeStroke.target.points = [ + ...activeStroke.target.points, + { x: event.clientX, y: event.clientY }, + ]; + activeStroke.target.bounds = strokeBounds( + activeStroke.target.points, + activeStroke.target.width, + ); + activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points)); + } + }; + + const onPointerDown = (event: PointerEvent): void => { + if (event.button !== 0 || isAnnotationNode(event.target as Element)) return; + event.preventDefault(); + event.stopPropagation(); + if (tool === "select") { + const target = pickFromPoint(event.clientX, event.clientY); + if (target) toggleSelected(target, event.shiftKey); + return; + } + if (tool === "erase") { + removeTargetAtPoint(event.clientX, event.clientY); + return; + } + dragStart = { x: event.clientX, y: event.clientY }; + if (tool === "draw") { + const stroke: PreviewAnnotationStrokeTarget = { + id: nextId("stroke"), + color: annotationTheme?.primary ?? "#2563eb", + width: 4, + points: [dragStart], + bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, + }; + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute(OVERLAY_ATTRIBUTE, ""); + path.setAttribute("data-stroke-id", stroke.id); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", stroke.color); + path.setAttribute("stroke-width", String(stroke.width)); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + svg.appendChild(path); + activeStroke = { target: stroke, path }; + } + }; + + const onPointerUp = (event: PointerEvent): void => { + if (!dragStart) return; + event.preventDefault(); + event.stopPropagation(); + if (tool === "marquee") { + const rect = normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY); + marqueeBox.style.display = "none"; + if (isUsableRect(rect)) { + const found = selectElementsInRect(rect); + if (found === 0) { + const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; + regions.push(region); + const regionBox = createBox( + PRIMARY, + "color-mix(in srgb, var(--t3-primary) 6%, transparent)", + ); + regionBox.setAttribute("data-region-id", region.id); + positionBox(regionBox, rect); + root.appendChild(regionBox); + } + } + } else if (tool === "draw" && activeStroke) { + if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target); + else activeStroke.path.remove(); + activeStroke = null; + } + dragStart = null; + updateStatus(); + }; + + const onClick = (event: MouseEvent): void => { + if (isAnnotationNode(event.target as Element)) return; + event.preventDefault(); + event.stopPropagation(); + }; + + const onPointerOut = (event: PointerEvent): void => { + if (event.relatedTarget === null) clearHoverOutline(); + }; + + const onWindowBlur = (): void => { + clearHoverOutline(); + }; + + const restoreStyles = (): void => { + for (const target of selected.values()) { + if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) + continue; + for (const [property, baseline] of target.baselineStyles) { + if (baseline) target.element.style.setProperty(property, baseline); + else target.element.style.removeProperty(property); + } + } + }; + + const teardown = (notifyMain: boolean): void => { + if (finished) return; + finished = true; + restoreStyles(); + window.removeEventListener("pointermove", onPointerMove, true); + window.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("pointerup", onPointerUp, true); + window.removeEventListener("pointerout", onPointerOut, true); + window.removeEventListener("click", onClick, true); + window.removeEventListener("blur", onWindowBlur); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("scroll", repaint, true); + window.removeEventListener("resize", repaint); + dragHandle.removeEventListener("pointerdown", onEditorPointerDown); + dragHandle.removeEventListener("pointermove", onEditorPointerMove); + dragHandle.removeEventListener("pointerup", onEditorPointerUp); + dragHandle.removeEventListener("pointercancel", onEditorPointerUp); + if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); + ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); + ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + document.documentElement.removeAttribute("data-t3code-annotation-tool"); + cursorStyle.remove(); + host.remove(); + activeSession = null; + if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); + }; + + const onCancel = (): void => teardown(false); + const onCaptured = (): void => teardown(false); + const onKeyDown = (event: KeyboardEvent): void => { + if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + teardown(true); + return; + } + if (event.key === "v") tool = "select"; + else if (event.key === "r") tool = "marquee"; + else if (event.key === "d") tool = "draw"; + else if (event.key === "e") tool = "erase"; + else return; + refreshToolButtons(); + }; + + submit.addEventListener("click", () => { + if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) + return; + pendingCapture = true; + submit.disabled = true; + submit.textContent = "Capturing…"; + void Promise.all( + Array.from(selected.values()).map(async (target) => { + const element = await captureElement(target.element); + if (!element) return null; + for (const change of styleChanges.values()) { + if (change.targetId === target.id) change.selector = element.selector; + } + return { + id: target.id, + element, + rect: rectFromDomRect(target.element.getBoundingClientRect()), + }; + }), + ).then((captured) => { + const elements = captured.filter((target) => target !== null); + const annotation: PreviewAnnotationPayload = { + id: nextId("annotation"), + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + comment: comment.value.trim(), + elements, + regions: [...regions], + strokes: [...strokes], + styleChanges: Array.from(styleChanges.values()), + screenshot: null, + createdAt: new Date().toISOString(), + }; + editor.style.display = "none"; + toolbar.style.display = "none"; + hoverOutline.style.display = "none"; + const screenshotRect = unionRects([ + ...elements.map((target) => target.rect), + ...regions.map((region) => region.rect), + ...strokes.map((stroke) => stroke.bounds), + ]); + ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect); + }); + }); + comment.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return; + event.preventDefault(); + submit.click(); + }); + + window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); + window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false }); + window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false }); + window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true }); + window.addEventListener("click", onClick, { capture: true, passive: false }); + window.addEventListener("blur", onWindowBlur); + window.addEventListener("keydown", onKeyDown, { capture: true }); + window.addEventListener("scroll", repaint, { capture: true, passive: true }); + window.addEventListener("resize", repaint, { passive: true }); + ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); + ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + document.documentElement.appendChild(host); + refreshToolButtons(); + updateStatus(); + activeSession = { + teardown, + applyTheme: (theme) => applyAnnotationTheme(host, theme), + }; +} + +ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => { + if (theme) annotationTheme = theme; + startAnnotation(); +}); +ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { + annotationTheme = theme; + activeSession?.applyTheme(theme); +}); +ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); diff --git a/apps/desktop/src/preview/PickedElementPayload.test.ts b/apps/desktop/src/preview/PickedElementPayload.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7a967324771fa4cf8130d31e06b4aee9505b2c3 --- /dev/null +++ b/apps/desktop/src/preview/PickedElementPayload.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isPickedElementPayload, isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; + +function validPayload(overrides?: Record): Record { + return { + pageUrl: "https://example.com/", + pageTitle: "Example", + tagName: "button", + selector: "button.submit", + htmlPreview: "", + componentName: "SubmitButton", + source: { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + stack: [ + { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + ], + styles: ".submit { color: white; }", + pickedAt: "2026-05-03T18:00:00.000Z", + ...overrides, + }; +} + +describe("isPickedElementPayload", () => { + it("accepts a complete, well-typed payload", () => { + expect(isPickedElementPayload(validPayload())).toBe(true); + }); + + it("accepts nullable string fields when null", () => { + expect( + isPickedElementPayload( + validPayload({ pageTitle: null, selector: null, componentName: null, source: null }), + ), + ).toBe(true); + }); + + it("accepts an empty stack array", () => { + expect(isPickedElementPayload(validPayload({ stack: [] }))).toBe(true); + }); + + it("accepts stack frames with null fields", () => { + expect( + isPickedElementPayload( + validPayload({ + stack: [ + { + functionName: null, + fileName: null, + lineNumber: null, + columnNumber: null, + }, + ], + }), + ), + ).toBe(true); + }); + + it("rejects null and primitive inputs", () => { + expect(isPickedElementPayload(null)).toBe(false); + expect(isPickedElementPayload(undefined)).toBe(false); + expect(isPickedElementPayload("string")).toBe(false); + expect(isPickedElementPayload(42)).toBe(false); + expect(isPickedElementPayload([])).toBe(false); + }); + + it.each<[string, Record]>([ + ["missing pageUrl", validPayload({ pageUrl: undefined })], + ["wrong-type pageUrl", validPayload({ pageUrl: 123 })], + ["missing tagName", validPayload({ tagName: undefined })], + ["missing htmlPreview", validPayload({ htmlPreview: undefined })], + ["missing styles", validPayload({ styles: undefined })], + ["missing pickedAt", validPayload({ pickedAt: undefined })], + ["wrong-type pageTitle", validPayload({ pageTitle: 99 })], + ["wrong-type selector", validPayload({ selector: 99 })], + ["wrong-type componentName", validPayload({ componentName: 99 })], + ])("rejects payloads with %s", (_label, value) => { + expect(isPickedElementPayload(value)).toBe(false); + }); + + it("rejects malformed source frames", () => { + expect( + isPickedElementPayload( + validPayload({ + source: { + functionName: 0, + fileName: null, + lineNumber: null, + columnNumber: null, + }, + }), + ), + ).toBe(false); + }); + + it("rejects non-finite numeric line/column numbers", () => { + expect( + isPickedElementPayload( + validPayload({ + source: { + functionName: null, + fileName: null, + lineNumber: Number.POSITIVE_INFINITY, + columnNumber: null, + }, + }), + ), + ).toBe(false); + expect( + isPickedElementPayload( + validPayload({ + source: { + functionName: null, + fileName: null, + lineNumber: Number.NaN, + columnNumber: null, + }, + }), + ), + ).toBe(false); + }); + + it("rejects malformed stack arrays", () => { + expect(isPickedElementPayload(validPayload({ stack: "not-an-array" }))).toBe(false); + expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false); + }); +}); + +function validAnnotation(overrides?: Record): Record { + return { + id: "annotation_1", + pageUrl: "https://example.com/", + pageTitle: "Example", + comment: "Make this clearer", + elements: [ + { + id: "element_1", + element: validPayload(), + rect: { x: 10, y: 20, width: 100, height: 40 }, + }, + ], + regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], + strokes: [ + { + id: "stroke_1", + color: "#7c3aed", + width: 4, + points: [ + { x: 10, y: 10 }, + { x: 20, y: 20 }, + ], + bounds: { x: 6, y: 6, width: 18, height: 18 }, + }, + ], + styleChanges: [ + { + targetId: "element_1", + selector: "button.submit", + property: "opacity", + previousValue: "1", + value: "0.5", + }, + ], + screenshot: null, + createdAt: "2026-06-11T00:00:00.000Z", + ...overrides, + }; +} + +describe("isPreviewAnnotationPayload", () => { + it("accepts a structured annotation draft before screenshot capture", () => { + expect(isPreviewAnnotationPayload(validAnnotation())).toBe(true); + }); + + it("rejects screenshots supplied by the guest preload", () => { + expect(isPreviewAnnotationPayload(validAnnotation({ screenshot: { dataUrl: "bad" } }))).toBe( + false, + ); + }); + + it("rejects malformed geometry and nested element payloads", () => { + expect( + isPreviewAnnotationPayload( + validAnnotation({ regions: [{ id: "region_1", rect: { x: 0, y: 0, width: "wide" } }] }), + ), + ).toBe(false); + expect( + isPreviewAnnotationPayload( + validAnnotation({ elements: [{ id: "element_1", element: {}, rect: {} }] }), + ), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/preview/PickedElementPayload.ts b/apps/desktop/src/preview/PickedElementPayload.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2d596120dba29d7914bea71a00e853bb2e20c44 --- /dev/null +++ b/apps/desktop/src/preview/PickedElementPayload.ts @@ -0,0 +1,146 @@ +/** + * Strict structural validator for `PickedElementPayload` messages received + * from the in-page picker preload (`apps/desktop/src/preview/PickPreload.ts`) + * via `wc.ipc`. Lives in its own electron-free module so the validator is + * trivially unit-testable. + * + * Validation must be tight: downstream `normalizeElementContextSelection` + * calls `.trim()` on incoming strings, so a malformed payload (preload bug, + * future schema mismatch, malicious page that intercepts the preload's IPC + * channel via prototype pollution) would otherwise throw deep in the + * renderer and the chip silently never appears. + */ +import type { PickedElementPayload, PreviewAnnotationPayload } from "@t3tools/contracts"; + +function isStringOrNull(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + +function isFiniteNumberOrNull(value: unknown): value is number | null { + return value === null || (typeof value === "number" && Number.isFinite(value)); +} + +function isPickedStackFrame(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const frame = value as Record; + return ( + isStringOrNull(frame["functionName"]) && + isStringOrNull(frame["fileName"]) && + isFiniteNumberOrNull(frame["lineNumber"]) && + isFiniteNumberOrNull(frame["columnNumber"]) + ); +} + +export function isPickedElementPayload(value: unknown): value is PickedElementPayload { + if (typeof value !== "object" || value === null) return false; + const c = value as Record; + if (typeof c["pageUrl"] !== "string") return false; + if (typeof c["tagName"] !== "string") return false; + if (typeof c["htmlPreview"] !== "string") return false; + if (typeof c["styles"] !== "string") return false; + if (typeof c["pickedAt"] !== "string") return false; + if (!isStringOrNull(c["pageTitle"])) return false; + if (!isStringOrNull(c["selector"])) return false; + if (!isStringOrNull(c["componentName"])) return false; + if (c["source"] !== null && !isPickedStackFrame(c["source"])) return false; + if (!Array.isArray(c["stack"])) return false; + if (!c["stack"].every(isPickedStackFrame)) return false; + return true; +} + +function isRect(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const rect = value as Record; + return ["x", "y", "width", "height"].every( + (key) => typeof rect[key] === "number" && Number.isFinite(rect[key]), + ); +} + +function isPoint(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const point = value as Record; + return ( + typeof point["x"] === "number" && + Number.isFinite(point["x"]) && + typeof point["y"] === "number" && + Number.isFinite(point["y"]) + ); +} + +export function isPreviewAnnotationPayload(value: unknown): value is PreviewAnnotationPayload { + if (typeof value !== "object" || value === null) return false; + const annotation = value as Record; + if (typeof annotation["id"] !== "string") return false; + if (typeof annotation["pageUrl"] !== "string") return false; + if (!isStringOrNull(annotation["pageTitle"])) return false; + if (typeof annotation["comment"] !== "string") return false; + if (typeof annotation["createdAt"] !== "string") return false; + if (annotation["screenshot"] !== null) return false; + + const elements = annotation["elements"]; + if (!Array.isArray(elements)) return false; + if ( + !elements.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const target = entry as Record; + return ( + typeof target["id"] === "string" && + isPickedElementPayload(target["element"]) && + isRect(target["rect"]) + ); + }) + ) { + return false; + } + + const regions = annotation["regions"]; + if (!Array.isArray(regions)) return false; + if ( + !regions.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const target = entry as Record; + return typeof target["id"] === "string" && isRect(target["rect"]); + }) + ) { + return false; + } + + const strokes = annotation["strokes"]; + if (!Array.isArray(strokes)) return false; + if ( + !strokes.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const target = entry as Record; + return ( + typeof target["id"] === "string" && + typeof target["color"] === "string" && + typeof target["width"] === "number" && + Number.isFinite(target["width"]) && + Array.isArray(target["points"]) && + target["points"].every(isPoint) && + isRect(target["bounds"]) + ); + }) + ) { + return false; + } + + const styleChanges = annotation["styleChanges"]; + if (!Array.isArray(styleChanges)) return false; + if ( + !styleChanges.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const change = entry as Record; + return ( + typeof change["targetId"] === "string" && + isStringOrNull(change["selector"]) && + typeof change["property"] === "string" && + typeof change["previousValue"] === "string" && + typeof change["value"] === "string" + ); + }) + ) { + return false; + } + return true; +} diff --git a/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..02d80baf63c6bad083d32b89fbb25261b73c5f09 --- /dev/null +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + playwrightInjectedRuntimeInstallExpression, + playwrightInjectedRuntimeSource, +} from "./PlaywrightInjectedRuntime.ts"; + +describe("playwright injected runtime", () => { + it("extracts the pinned runtime from playwright-core", async () => { + const source = await playwrightInjectedRuntimeSource(); + expect(source.length).toBeGreaterThan(100_000); + expect(source).toContain("InjectedScript"); + }); + + it("builds an idempotent install expression", async () => { + const expression = await playwrightInjectedRuntimeInstallExpression(); + expect(expression).toContain("__t3PlaywrightInjected"); + expect(expression).toContain('testIdAttributeName":"data-testid'); + }); +}); diff --git a/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4d7c6bd9402227e77763d866ba5cfa347bd8544 --- /dev/null +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts @@ -0,0 +1,49 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { readFile } from "node:fs/promises"; +import { runInNewContext } from "node:vm"; + +const require = createRequire(import.meta.url); +let sourcePromise: Promise | null = null; + +export const playwrightInjectedRuntimeSource = (): Promise => { + sourcePromise ??= (async () => { + const packageJsonPath = require.resolve("playwright-core/package.json"); + const coreBundle = await readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8"); + const marker = "source3 = "; + const start = coreBundle.indexOf(marker); + if (start < 0) throw new Error("Playwright injected runtime marker was not found."); + const literalStart = start + marker.length; + const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart); + if (literalEnd < 0) throw new Error("Playwright injected runtime terminator was not found."); + const literal = coreBundle.slice(literalStart, literalEnd); + const source = runInNewContext(literal, Object.create(null), { timeout: 1_000 }); + if (typeof source !== "string" || source.length < 100_000) { + throw new Error("Playwright injected runtime extraction returned invalid source."); + } + return source; + })(); + return sourcePromise; +}; + +export const playwrightInjectedRuntimeInstallExpression = async (): Promise => { + const source = await playwrightInjectedRuntimeSource(); + const options = { + isUnderTest: false, + sdkLanguage: "javascript", + testIdAttributeName: "data-testid", + stableRafCount: 1, + browserName: "chromium", + shouldPrependErrorPrefix: false, + isUtilityWorld: false, + customEngines: [], + }; + return `(() => { + if (globalThis.__t3PlaywrightInjected) return true; + const module = { exports: {} }; + ${source} + globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${JSON.stringify(options)}); + return true; + })()`; +}; diff --git a/apps/desktop/src/preview/WebviewPreferences.test.ts b/apps/desktop/src/preview/WebviewPreferences.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..498c1df4665bade94cd1afd1188601280615cb66 --- /dev/null +++ b/apps/desktop/src/preview/WebviewPreferences.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { PREVIEW_WEBVIEW_PREFERENCES } from "./WebviewPreferences.ts"; + +/** + * Mirrors Electron's webview attribute parser closely enough to catch the + * regressions we've already hit: + * + * - whitespace inside the comma-separated list silently drops keys (so + * `" sandbox=true"` becomes an unknown key and Electron falls back to + * defaults — re-opening the Node-leak window we closed), + * - non-`true`/`false` values (`"yes"`, `"no"`, etc.) are kept as truthy + * strings and assigned to a boolean preference, which silently flips + * `contextIsolation=no` to ENABLED (then react-grab can't see the React + * DevTools hook and componentName resolution always returns null). + * + * The actual Electron parser does roughly: + * + * for (const pair of webpreferences.split(',')) { + * const [key, value] = pair.split('='); + * prefs[key] = value; // value left as a string + * } + * + * then later coerces booleans via `Boolean(value)`. Replicating that here + * keeps the test independent of Electron internals while still failing if + * we accidentally ship `"contextIsolation=no"` again. + */ +function parseWebPreferences(input: string): Record { + const out: Record = {}; + for (const pair of input.split(",")) { + if (pair !== pair.trim()) { + // Electron's parser doesn't trim; surface the bug as undefined-key. + out[pair] = pair.split("=")[1]; + continue; + } + const [key, value] = pair.split("="); + if (!key) continue; + out[key] = value; + } + return out; +} + +describe("PREVIEW_WEBVIEW_PREFERENCES", () => { + const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES); + + it("contains exactly the three security-critical keys", () => { + expect(Object.keys(parsed).toSorted()).toEqual( + ["contextIsolation", "nodeIntegration", "sandbox"].toSorted(), + ); + }); + + it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => { + // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so + // `contextIsolation="no"` would silently leave isolation ENABLED. Lock + // the values to `"true"` / `"false"` so the parser does the right thing. + for (const value of Object.values(parsed)) { + expect(value).toMatch(/^(true|false)$/); + } + }); + + it("disables context isolation (so react-grab can see the page's React DevTools hook)", () => { + expect(parsed["contextIsolation"]).toBe("false"); + }); + + it("keeps the renderer sandbox enabled (so the page cannot reach Node APIs)", () => { + expect(parsed["sandbox"]).toBe("true"); + }); + + it("disables nodeIntegration (defense in depth — page never gets Node)", () => { + expect(parsed["nodeIntegration"]).toBe("false"); + }); + + it("contains no whitespace (Electron's parser does not trim)", () => { + // Electron splits on `,` without trimming, so any whitespace would turn + // a key into an unknown one and silently drop the security flag. + expect(PREVIEW_WEBVIEW_PREFERENCES).not.toMatch(/\s/); + }); +}); diff --git a/apps/desktop/src/preview/WebviewPreferences.ts b/apps/desktop/src/preview/WebviewPreferences.ts new file mode 100644 index 0000000000000000000000000000000000000000..085c75232b382b82c8791df510fd2843284f2c0f --- /dev/null +++ b/apps/desktop/src/preview/WebviewPreferences.ts @@ -0,0 +1,42 @@ +/** + * webPreferences override applied to every preview `` element via + * its `webpreferences="..."` attribute. Single source of truth so all guest + * surfaces inherit the same security posture. + * + * Lives in its own electron-free module so the value is unit-testable + * without importing `Manager.ts` (which transitively imports + * `electron` and blows up under vitest). + * + * - `contextIsolation=false`: the picker preload needs to share `globalThis` + * with the page so react-grab/bippy can read the React DevTools hook + * (`__REACT_DEVTOOLS_GLOBAL_HOOK__`) and resolve component names. Without + * this every pick comes back with `componentName: null` even on dev React + * apps. + * - `sandbox=true`: keeps the OS-level renderer sandbox enabled. Critical + * when paired with `contextIsolation=false` — without sandbox, the preload + * has full Node access (`require`, `fs`, `child_process`, ...) and that + * `require` would land on the page's shared `globalThis`, giving any + * third-party page in the preview full Node + IPC access to the host. + * In sandboxed mode Electron still synthesizes the `electron` module for + * the preload's `import { ipcRenderer }` line, but no Node globals leak. + * - `nodeIntegration=false`: pinned for clarity (the page itself never gets + * Node access). + * + * Format notes (locked down by `WebviewPreferences.test.ts`): + * - Whitespace-free. Electron's webpreferences parser splits on `,` and + * does not trim, so a leading space would turn a key into an unknown one + * and silently drop it. + * - Values are JS-boolean strings (`true`/`false`) — `yes`/`no` are not + * special-cased by the parser; `value="no"` becomes the truthy STRING + * `"no"` when assigned to a boolean webPreferences key. Most critically, + * `contextIsolation="no"` is truthy → contextIsolation stays ENABLED → + * react-grab can't see the React DevTools hook. + * + * Defense in depth: `apps/desktop/src/main.ts` also runs a + * `will-attach-webview` handler that force-sets `sandbox: true` and + * `nodeIntegration*: false` on the actual webPreferences object, gated on + * the preview partition, so even if this string is ever wrong, the + * security-critical flags can't regress on preview tabs. + */ +export const PREVIEW_WEBVIEW_PREFERENCES = + "contextIsolation=false,sandbox=true,nodeIntegration=false"; diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 01dbf3af1e851f97761b15aaf5cef3d2a0ff9992..569ac771c7303ddbb777787f9fa187d947b5a267 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -29,6 +29,7 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; +import * as PreviewManager from "../preview/Manager.ts"; const environmentInput = { dirname: "/repo/apps/desktop/dist-electron", @@ -166,6 +167,12 @@ function makeTestLayer(input: { } satisfies ElectronShell.ElectronShellShape), electronThemeLayer, electronWindowLayer, + Layer.mock(PreviewManager.PreviewManager)({ + getBrowserSession: () => Effect.succeed({} as Electron.Session), + setMainWindow: () => Effect.void, + isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), + getBrowserPartition: () => "persist:t3code-preview-test", + }), ), ), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 953c7a3adb2e6c41f872b2907304a35346c949fc..f24485fd879e7fb8f7ebff18bcfef0cbaf4f452d 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -11,7 +11,7 @@ import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; -import { previewViewManager } from "../preview-view-manager.ts"; +import * as PreviewManager from "../preview/Manager.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -37,7 +37,8 @@ type DesktopWindowRuntimeServices = | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme - | ElectronWindow.ElectronWindow; + | ElectronWindow.ElectronWindow + | PreviewManager.PreviewManager; export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( "DesktopWindowDevServerUrlMissingError", @@ -49,7 +50,8 @@ export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( export type DesktopWindowError = | DesktopWindowDevServerUrlMissingError - | ElectronWindow.ElectronWindowCreateError; + | ElectronWindow.ElectronWindowCreateError + | PreviewManager.PreviewManagerError; export interface DesktopWindowShape { readonly createMain: Effect.Effect; @@ -163,6 +165,7 @@ const make = Effect.gen(function* () { const electronShell = yield* ElectronShell.ElectronShell; const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; + const previewManager = yield* PreviewManager.PreviewManager; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const state = yield* DesktopState.DesktopState; const context = yield* Effect.context(); @@ -171,7 +174,7 @@ const make = Effect.gen(function* () { const createWindow = Effect.fn("desktop.window.createWindow")(function* ( backendHttpUrl: URL, ): Effect.fn.Return { - previewViewManager.getBrowserSession(); + yield* previewManager.getBrowserSession(); const applicationUrl = environment.isDevelopment ? yield* resolveDesktopDevServerUrl(environment) : backendHttpUrl.href; @@ -198,11 +201,11 @@ const make = Effect.gen(function* () { }, }); - previewViewManager.setMainWindow(window); + yield* previewManager.setMainWindow(window); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { if ( typeof params.partition !== "string" || - !previewViewManager.isBrowserPartition(params.partition) + !previewManager.isBrowserPartition(params.partition) ) { event.preventDefault(); return; diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index d13eb00422214d9f476fdee3ac1df4ec002a78c5..3ae91bf8baf2250b94e8270cc0d25b5c8def3fb5 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import type * as Types from "effect/Types"; import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; @@ -29,6 +30,20 @@ const unauthorized = HttpServerResponse.jsonUnsafe( }, ); +type AuthenticatedHttpEffect = Effect.Effect< + HttpServerResponse.HttpServerResponse, + Types.unhandled, + McpInvocationContext.McpInvocationContext +>; + +type McpAuthMiddleware = ( + httpEffect: AuthenticatedHttpEffect, +) => Effect.Effect< + HttpServerResponse.HttpServerResponse, + Types.unhandled, + HttpServerRequest.HttpServerRequest +>; + export const normalizeMcpHttpResponse = ( response: HttpServerResponse.HttpServerResponse, ): HttpServerResponse.HttpServerResponse => { @@ -41,27 +56,123 @@ export const normalizeMcpHttpResponse = ( : response; }; +const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe( + Effect.map( + (registry): McpAuthMiddleware => + Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { + const request = yield* HttpServerRequest.HttpServerRequest; + const authorization = request.headers.authorization; + const token = + authorization?.startsWith("Bearer ") === true + ? authorization.slice("Bearer ".length).trim() + : ""; + const invocation = yield* registry.resolve(token); + if (!invocation) return unauthorized; + return yield* httpEffect.pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.map(normalizeMcpHttpResponse), + ); + }), + ), + Effect.withSpan("McpHttpServer.makeAuthMiddleware"), +); + const McpAuthMiddlewareLive = HttpRouter.middleware<{ provides: McpInvocationContext.McpInvocationContext; -}>()( - Effect.gen(function* () { - const registry = yield* McpSessionRegistry.McpSessionRegistry; - return Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { - const request = yield* HttpServerRequest.HttpServerRequest; - const authorization = request.headers.authorization; - const token = - authorization?.startsWith("Bearer ") === true - ? authorization.slice("Bearer ".length).trim() - : ""; - const invocation = yield* registry.resolve(token); - if (!invocation) return unauthorized; - return yield* httpEffect.pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.map(normalizeMcpHttpResponse), - ); +}>()(makeMcpAuthMiddleware).layer; + +const registerPreviewToolkit = Effect.fn("McpHttpServer.registerPreviewToolkit")(function* () { + const server = yield* McpServer.McpServer; + const built = yield* PreviewToolkit; + const handleTool = built.handle as unknown as ( + name: keyof typeof built.tools, + payload: unknown, + ) => Effect.Effect< + Stream.Stream<{ readonly encodedResult: unknown }, Error>, + Error, + McpInvocationContext.McpInvocationContext + >; + for (const tool of Object.values(built.tools)) { + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: tool.name, + description: Tool.getDescription(tool), + inputSchema: Tool.getJsonSchema(tool), + annotations: { + ...Context.getOption(tool.annotations, Tool.Title).pipe( + Option.map((title) => ({ title })), + Option.getOrUndefined, + ), + readOnlyHint: Context.get(tool.annotations, Tool.Readonly), + destructiveHint: Context.get(tool.annotations, Tool.Destructive), + idempotentHint: Context.get(tool.annotations, Tool.Idempotent), + openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), + }, + }), + annotations: tool.annotations, + handle: (payload) => + handleTool(tool.name as keyof typeof built.tools, payload).pipe( + Stream.unwrap, + Stream.run(Sink.last()), + Effect.flatMap(Effect.fromOption), + Effect.matchCause({ + onFailure: (cause) => + new McpSchema.CallToolResult({ + isError: true, + content: [{ type: "text", text: Cause.pretty(cause) }], + }), + onSuccess: (result) => { + if (tool.name === "preview_snapshot") { + const snapshot = result.encodedResult as { + readonly screenshot: { + readonly mimeType: "image/png"; + readonly data: string; + readonly width: number; + readonly height: number; + }; + readonly [key: string]: unknown; + }; + const { screenshot, ...page } = snapshot; + const metadata = { + ...page, + screenshot: { + mimeType: screenshot.mimeType, + width: screenshot.width, + height: screenshot.height, + }, + }; + return new McpSchema.CallToolResult({ + isError: false, + structuredContent: metadata, + content: [ + { type: "text", text: JSON.stringify(metadata) }, + { + type: "image", + data: new Uint8Array(Buffer.from(screenshot.data, "base64")), + mimeType: screenshot.mimeType, + }, + ], + }); + } + const encodedResultText = JSON.stringify(result.encodedResult) ?? "null"; + return new McpSchema.CallToolResult({ + isError: false, + structuredContent: + result.encodedResult !== null && typeof result.encodedResult === "object" + ? result.encodedResult + : undefined, + content: [{ type: "text", text: encodedResultText }], + }); + }, + }), + ) as unknown as Effect.Effect, }); - }), -).layer; + } +}); + +export const PreviewToolkitRegistrationLive = Layer.effectDiscard(registerPreviewToolkit()).pipe( + Layer.provide(PreviewToolkitHandlersLive), +); const McpTransportLive = McpServer.layerHttp({ name: "T3 Code", @@ -69,98 +180,7 @@ const McpTransportLive = McpServer.layerHttp({ path: "/mcp", }).pipe(Layer.provide(McpAuthMiddlewareLive)); -export const PreviewToolkitRegistrationLive = Layer.effectDiscard( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const built = yield* PreviewToolkit; - const handleTool = built.handle as unknown as ( - name: keyof typeof built.tools, - payload: unknown, - ) => Effect.Effect< - Stream.Stream<{ readonly encodedResult: unknown }, Error>, - Error, - McpInvocationContext.McpInvocationContext - >; - for (const tool of Object.values(built.tools)) { - yield* server.addTool({ - tool: new McpSchema.Tool({ - name: tool.name, - description: Tool.getDescription(tool), - inputSchema: Tool.getJsonSchema(tool), - annotations: { - ...Context.getOption(tool.annotations, Tool.Title).pipe( - Option.map((title) => ({ title })), - Option.getOrUndefined, - ), - readOnlyHint: Context.get(tool.annotations, Tool.Readonly), - destructiveHint: Context.get(tool.annotations, Tool.Destructive), - idempotentHint: Context.get(tool.annotations, Tool.Idempotent), - openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), - }, - }), - annotations: tool.annotations, - handle: (payload) => - handleTool(tool.name as keyof typeof built.tools, payload).pipe( - Stream.unwrap, - Stream.run(Sink.last()), - Effect.flatMap(Effect.fromOption), - Effect.matchCause({ - onFailure: (cause) => - new McpSchema.CallToolResult({ - isError: true, - content: [{ type: "text", text: Cause.pretty(cause) }], - }), - onSuccess: (result) => { - if (tool.name === "preview_snapshot") { - const snapshot = result.encodedResult as { - readonly screenshot: { - readonly mimeType: "image/png"; - readonly data: string; - readonly width: number; - readonly height: number; - }; - readonly [key: string]: unknown; - }; - const { screenshot, ...page } = snapshot; - const metadata = { - ...page, - screenshot: { - mimeType: screenshot.mimeType, - width: screenshot.width, - height: screenshot.height, - }, - }; - return new McpSchema.CallToolResult({ - isError: false, - structuredContent: metadata, - content: [ - { type: "text", text: JSON.stringify(metadata) }, - { - type: "image", - data: new Uint8Array(Buffer.from(screenshot.data, "base64")), - mimeType: screenshot.mimeType, - }, - ], - }); - } - const encodedResultText = JSON.stringify(result.encodedResult) ?? "null"; - return new McpSchema.CallToolResult({ - isError: false, - structuredContent: - result.encodedResult !== null && typeof result.encodedResult === "object" - ? result.encodedResult - : undefined, - content: [{ type: "text", text: encodedResultText }], - }); - }, - }), - ) as unknown as Effect.Effect, - }); - } - }), -).pipe(Layer.provide(PreviewToolkitHandlersLive)); - -export const layer = Layer.mergeAll(PreviewToolkitRegistrationLive).pipe( +export const layer = PreviewToolkitRegistrationLive.pipe( Layer.provideMerge(McpTransportLive), Layer.provide(PreviewAutomationBroker.layer), ); diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index d345953caeb82fbca2f24937d44923d28b7e99dc..d6540d567af3b71ac8393748a32efa5d36066df3 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -18,15 +18,17 @@ const fakeEnvironment = ServerEnvironment.of({ }); const makeRegistry = (now: () => number) => - McpSessionRegistry.makeForTest({ - now, - idleTimeoutMs: 100, - maximumLifetimeMs: 1_000, - }).pipe( - Effect.provideService(HttpServer.HttpServer, fakeHttpServer), - Effect.provideService(ServerEnvironment, fakeEnvironment), - Effect.provide(NodeServices.layer), - ); + McpSessionRegistry.__testing + .make({ + now, + idleTimeoutMs: 100, + maximumLifetimeMs: 1_000, + }) + .pipe( + Effect.provideService(HttpServer.HttpServer, fakeHttpServer), + Effect.provideService(ServerEnvironment, fakeEnvironment), + Effect.provide(NodeServices.layer), + ); it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () => Effect.gen(function* () { diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index c87abe88bb5022df614fae62262a318c27d8a0e7..161b560f7465bd89db43b61e06421dfdaf1cdb78 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -184,8 +184,6 @@ export const layer: Layer.Layer< ), ); -export const makeForTest = make; - export const issueActiveMcpCredential = ( request: McpCredentialRequest, ): Effect.Effect => @@ -200,3 +198,8 @@ export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect = export const revokeAllActiveMcpCredentials = (): Effect.Effect => activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; + +/** Exposed for tests. */ +export const __testing = { + make, +}; diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index c5d316fd87fd9fe47ef3cf93c1882f33c16f8b14..7d702cd970bc9e7eb2447241eed63dcd009f247a 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -23,7 +23,7 @@ const scope = { it.effect("routes a request to the focused owner and correlates its response", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.makeForTest; + const broker = yield* PreviewAutomationBroker.__testing.make(); const requests = yield* broker.connect("client-1"); yield* Stream.runForEach(requests, (request) => broker.respond({ @@ -56,7 +56,7 @@ it.effect("routes a request to the focused owner and correlates its response", ( it.effect("rejects calls when no focused owner exists", () => Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.makeForTest; + const broker = yield* PreviewAutomationBroker.__testing.make(); const error = yield* broker .invoke({ scope, operation: "status", input: {} }) .pipe(Effect.flip); @@ -67,7 +67,7 @@ it.effect("rejects calls when no focused owner exists", () => it.effect("routes interactive commands to a hidden durable browser host", () => Effect.scoped( Effect.gen(function* () { - const broker = yield* PreviewAutomationBroker.makeForTest; + const broker = yield* PreviewAutomationBroker.__testing.make(); const requests = yield* broker.connect("client-hidden"); yield* Stream.runForEach(requests, (request) => broker.respond({ requestId: request.requestId, ok: true }), diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 68080bd93606b9dcddf40e43237f9ed4cd544bfc..a8ae4a9eec4dec9da3ffcde8cc2fa7fde412b416 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -119,14 +119,12 @@ const makeResponseError = ( } }; -const makeService = (): PreviewAutomationBrokerShape => { - const state = Effect.runSync( - Ref.make({ - clients: new Map(), - owners: new Map(), - pending: new Map(), - }), - ); +const make = Effect.fn("PreviewAutomationBroker.make")(function* () { + const state = yield* Ref.make({ + clients: new Map(), + owners: new Map(), + pending: new Map(), + }); let requestSequence = 0; const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( @@ -297,10 +295,11 @@ const makeService = (): PreviewAutomationBrokerShape => { }); return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); +}); + +export const layer = Layer.effect(PreviewAutomationBroker, make()); + +/** Exposed for tests. */ +export const __testing = { + make, }; - -const service = makeService(); -const make = Effect.succeed(service); -export const layer = Layer.effect(PreviewAutomationBroker, make); - -export const makeForTest = Effect.sync(makeService); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 8a52dbcc8c34000ce5c0ae60e0ca3bc6c2fff602..e18bbdaf49353c46bc7723d7efefce41e345fdaf 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -127,7 +127,7 @@ const buildIdleSnapshot = (input: { updatedAt: input.updatedAt, }); -const make = Effect.gen(function* () { +const make = Effect.fn("PreviewManager.make")(function* () { const stateRef = yield* SynchronizedRef.make(initialState); // Unbounded PubSub is fine here — events are tiny and we don't want to // block publishers if a subscriber is slow. WS clients backpressure on @@ -359,4 +359,4 @@ const make = Effect.gen(function* () { } satisfies PreviewManagerShape; }); -export const layer = Layer.effect(PreviewManager, make); +export const layer = Layer.effect(PreviewManager, make()); diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 0f75a538ec6e4d5898f5727d3f1f61753fdf3240..350268acff6622f93c42fa1edd32a1c692397665 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -2,8 +2,9 @@ import * as net from "node:net"; import { it as effectIt } from "@effect/vitest"; import { ThreadId } from "@t3tools/contracts"; +import * as Net from "@t3tools/shared/Net"; import { Effect, Layer } from "effect"; -import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { ProcessRunner } from "../processRunner.ts"; import * as PortScanner from "./PortScanner.ts"; @@ -13,7 +14,60 @@ const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serv const TestProcessRunner = Layer.succeed(ProcessRunner, { run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), }); -const TestPortDiscoveryLive = PortScanner.layer.pipe(Layer.provide(TestProcessRunner)); +const TestPortDiscoveryLive = PortScanner.layer.pipe( + Layer.provide(Layer.mergeAll(TestProcessRunner, Net.layer)), +); + +const openServer = (port: number): Effect.Effect => + Effect.callback((resume) => { + const server = net.createServer(); + server.once("error", () => { + resume(Effect.succeed(null)); + }); + server.listen(port, "127.0.0.1", () => { + resume(Effect.succeed(server)); + }); + return Effect.sync(() => { + server.close(); + }); + }); + +const closeServer = (server: net.Server): Effect.Effect => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }); + +const windowsPlatform = Effect.acquireRelease( + Effect.sync(() => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + return originalPlatform; + }), + (originalPlatform) => + Effect.sync(() => { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + }), +); + +const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( + ports: ReadonlyArray, +) { + for (const port of ports) { + const server = yield* openServer(port); + if (server !== null) return { port, server }; + } + return yield* Effect.die( + new Error("No common development port was available for the preview scanner test"), + ); +}); + +const commonDevServer = Effect.acquireRelease( + openCommonDevServer(PortScanner.COMMON_DEV_PORTS), + ({ server }) => closeServer(server), +); describe("parsePortFromLsofName", () => { it("parses *:port", () => { @@ -180,51 +234,26 @@ describe("parseWindowsListenerOutput", () => { * path (TCP-probe fallback) by monkey-patching `process.platform` for the * duration of the test so we don't depend on `lsof` being installed. */ -describe("PortDiscovery integration (TCP probe fallback)", () => { - let originalPlatform: NodeJS.Platform; - let server: net.Server; - let port: number; - - beforeEach(async () => { - originalPlatform = process.platform; - Object.defineProperty(process, "platform", { value: "win32", configurable: true }); - for (const candidate of PortScanner.COMMON_DEV_PORTS) { - const candidateServer = net.createServer(); - const listening = await new Promise((resolve) => { - candidateServer.once("error", () => resolve(false)); - candidateServer.listen(candidate, "127.0.0.1", () => resolve(true)); - }); - if (listening) { - server = candidateServer; - port = candidate; - return; - } - candidateServer.close(); - } - throw new Error("No common development port was available for the preview scanner test"); - }); - - afterEach(async () => { - Object.defineProperty(process, "platform", { - value: originalPlatform, - configurable: true, - }); - await new Promise((resolve) => server.close(() => resolve())); - }); - - effectIt.effect("scan() returns a server we just opened on a curated dev port", () => - Effect.gen(function* () { +effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => { + it.effect( + "scan() returns a server we just opened on a curated dev port", + Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { + yield* windowsPlatform; + const { port } = yield* commonDevServer; const scanner = yield* PortScanner.PortDiscovery; const result = yield* scanner.scan(); const found = result.find((server) => server.port === port); expect(found).toBeDefined(); expect(found?.host).toBe("localhost"); - }).pipe(Effect.provide(TestPortDiscoveryLive)), + }), ); - effectIt.effect("retain() drives an immediate broadcast to subscribers", () => { - const received: number[] = []; - return Effect.gen(function* () { + it.effect( + "retain() drives an immediate broadcast to subscribers", + Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () { + yield* windowsPlatform; + const { port } = yield* commonDevServer; + const received: number[] = []; const scanner = yield* PortScanner.PortDiscovery; const unsubscribe = yield* scanner.subscribe((servers) => Effect.sync(() => { @@ -235,6 +264,6 @@ describe("PortDiscovery integration (TCP probe fallback)", () => { unsubscribe(); release(); expect(received).toContain(port); - }).pipe(Effect.provide(TestPortDiscoveryLive)); - }); + }), + ); }); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 008de8ae798539dfc5a326e16a4368b5753aa156..d2ad4f43c3735c070fe17bb85fa4518c97e529d4 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -5,15 +5,14 @@ * stable line-prefixed field format; this is the only `lsof` flag set we rely * on). * - * Windows / lsof missing: TCP-connects to a curated list of common dev ports - * on 127.0.0.1. + * Windows / lsof missing: checks a curated list of common dev ports through + * the shared Net service. * * Polling is reference-counted via `retain()`. A single layer-scoped fiber * polls forever, but each tick is a no-op when the retain count is zero. */ -import * as net from "node:net"; - import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; +import * as Net from "@t3tools/shared/Net"; import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; import { Cause, Context, Duration, Effect, Layer, Ref, Schedule } from "effect"; @@ -45,7 +44,6 @@ export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ ]); const POLL_INTERVAL = Duration.seconds(3); -const TCP_PROBE_TIMEOUT_MS = 200; const LSOF_TIMEOUT_MS = 5_000; const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; @@ -148,40 +146,6 @@ const parseWindowsListenerOutput = ( return [...seen.values()].toSorted((left, right) => left.port - right.port); }; -const probeTcpPort = (port: number): Promise => - new Promise((resolve) => { - const socket = new net.Socket(); - let settled = false; - const finish = (ok: boolean) => { - if (settled) return; - settled = true; - socket.destroy(); - resolve(ok); - }; - socket.setTimeout(TCP_PROBE_TIMEOUT_MS); - socket.once("timeout", () => finish(false)); - socket.once("error", () => finish(false)); - socket.once("connect", () => finish(true)); - socket.connect({ host: "127.0.0.1", port }); - }); - -const probeCommonPorts = (): Effect.Effect> => - Effect.promise(async () => { - const results = await Promise.all( - COMMON_DEV_PORTS.map(async (port) => ({ port, listening: await probeTcpPort(port) })), - ); - return results - .filter((r) => r.listening) - .map((r) => ({ - host: "localhost", - port: r.port, - url: `http://localhost:${r.port}`, - processName: null, - pid: null, - terminal: null, - })); - }); - const serversEqual = ( left: ReadonlyArray, right: ReadonlyArray, @@ -206,7 +170,8 @@ const serversEqual = ( return true; }; -const make = Effect.gen(function* () { +const make = Effect.fn("PortDiscovery.make")(function* () { + const net = yield* Net.NetService; const processRunner = yield* ProcessRunner; const stateRef = yield* Ref.make({ lastSnapshot: [], @@ -224,6 +189,30 @@ const make = Effect.gen(function* () { // be a synchronous side-effect-only function. let retainCount = 0; + const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { + const results = yield* Effect.forEach( + COMMON_DEV_PORTS, + (port) => + net.isPortAvailableOnLoopback(port).pipe( + Effect.map((available) => ({ + port, + listening: !available, + })), + ), + { concurrency: "unbounded" }, + ); + return results + .filter((result) => result.listening) + .map((result) => ({ + host: "localhost", + port: result.port, + url: `http://localhost:${result.port}`, + processName: null, + pid: null, + terminal: null, + })); + }); + const scanOnce = Effect.fn("PortDiscovery.scan")(function* () { const terminalByProcessId = new Map(); for (const registration of terminalProcesses.values()) { @@ -268,14 +257,15 @@ const make = Effect.gen(function* () { const broadcast = (servers: ReadonlyArray): Effect.Effect => Effect.forEach(Array.from(listeners), (listener) => listener(servers), { discard: true }); - const pollTick = Effect.gen(function* () { - if (retainCount <= 0) return; - const next = yield* scanOnce(); - const state = yield* Ref.get(stateRef); - if (serversEqual(state.lastSnapshot, next)) return; - yield* Ref.update(stateRef, (s) => ({ ...s, lastSnapshot: next })); - yield* broadcast(next); - }).pipe( + const pollTick = Effect.fn("PortDiscovery.pollTick")( + function* () { + if (retainCount <= 0) return; + const next = yield* scanOnce(); + const state = yield* Ref.get(stateRef); + if (serversEqual(state.lastSnapshot, next)) return; + yield* Ref.update(stateRef, (s) => ({ ...s, lastSnapshot: next })); + yield* broadcast(next); + }, Effect.catchCause((cause: Cause.Cause) => Effect.logWarning("preview port scan failed", Cause.pretty(cause)), ), @@ -283,7 +273,7 @@ const make = Effect.gen(function* () { // Single layer-scoped polling fiber. Ticks are no-ops when no client is // currently retained, so the cost is one Ref.get every POLL_INTERVAL. - yield* Effect.forkScoped(pollTick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); + yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); const retain: PortDiscoveryShape["retain"] = Effect.fn("PortDiscovery.retain")(function* () { const wasIdle = retainCount === 0; @@ -291,7 +281,7 @@ const make = Effect.gen(function* () { if (wasIdle) { // Run an immediate scan + broadcast so the new retainer doesn't have // to wait up to POLL_INTERVAL for the first emission. - yield* pollTick; + yield* pollTick(); } let released = false; return () => { @@ -349,7 +339,7 @@ const make = Effect.gen(function* () { } satisfies PortDiscoveryShape; }); -export const layer = Layer.effect(PortDiscovery, make); +export const layer = Layer.effect(PortDiscovery, make()); /** Exposed for tests. */ export const __testing = { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4236a5ff2a7eff7782f58489f29e76d61603d819..1739a27e55b64f30fb26eab10adf255346c501e1 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -66,7 +66,7 @@ import type { PreviewReportStatusInput, PreviewSessionSnapshot, } from "./preview.ts"; -import type { +import { PreviewAutomationClickInput, PreviewAutomationEvaluateInput, PreviewAutomationOwner, @@ -455,6 +455,42 @@ export interface DesktopPreviewTabState { updatedAt: string; } +export const DesktopPreviewTabIdSchema = Schema.String.check(Schema.isTrimmed()).check( + Schema.isNonEmpty(), +); + +export const DesktopPreviewNavStatusSchema = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("Idle") }), + Schema.Struct({ + kind: Schema.Literal("Loading"), + url: Schema.String, + title: Schema.String, + }), + Schema.Struct({ + kind: Schema.Literal("Success"), + url: Schema.String, + title: Schema.String, + }), + Schema.Struct({ + kind: Schema.Literal("LoadFailed"), + url: Schema.String, + title: Schema.String, + code: Schema.Number, + description: Schema.String, + }), +]); + +export const DesktopPreviewTabStateSchema: Schema.Codec = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + webContentsId: Schema.NullOr(Schema.Int), + navStatus: DesktopPreviewNavStatusSchema, + canGoBack: Schema.Boolean, + canGoForward: Schema.Boolean, + zoomFactor: Schema.Number, + controller: Schema.Literals(["human", "agent", "none"]), + updatedAt: Schema.String, +}); + export interface DesktopPreviewPointerEvent { tabId: string; phase: "move" | "click"; @@ -464,6 +500,16 @@ export interface DesktopPreviewPointerEvent { createdAt: string; } +export const DesktopPreviewPointerEventSchema: Schema.Codec = + Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + phase: Schema.Literals(["move", "click"]), + x: Schema.Number, + y: Schema.Number, + sequence: Schema.Int, + createdAt: Schema.String, + }); + /** * Static config a renderer needs to mount a preview ``. Returned * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer @@ -487,6 +533,13 @@ export interface DesktopPreviewWebviewConfig { preloadUrl: string | null; } +export const DesktopPreviewWebviewConfigSchema: Schema.Codec = + Schema.Struct({ + partition: Schema.String, + webPreferences: Schema.String, + preloadUrl: Schema.NullOr(Schema.String), + }); + export interface DesktopPreviewAnnotationTheme { colorScheme: "light" | "dark"; radius: string; @@ -507,6 +560,27 @@ export interface DesktopPreviewAnnotationTheme { fontMono: string; } +export const DesktopPreviewAnnotationThemeSchema: Schema.Codec = + Schema.Struct({ + colorScheme: Schema.Literals(["light", "dark"]), + radius: Schema.String, + background: Schema.String, + foreground: Schema.String, + popover: Schema.String, + popoverForeground: Schema.String, + primary: Schema.String, + primaryForeground: Schema.String, + muted: Schema.String, + mutedForeground: Schema.String, + accent: Schema.String, + accentForeground: Schema.String, + border: Schema.String, + input: Schema.String, + ring: Schema.String, + fontSans: Schema.String, + fontMono: Schema.String, + }); + export interface DesktopPreviewRecordingFrame { tabId: string; data: string; @@ -515,6 +589,15 @@ export interface DesktopPreviewRecordingFrame { receivedAt: string; } +export const DesktopPreviewRecordingFrameSchema: Schema.Codec = + Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + data: Schema.String, + width: Schema.Number, + height: Schema.Number, + receivedAt: Schema.String, + }); + export interface DesktopPreviewRecordingArtifact { id: string; tabId: string; @@ -524,6 +607,16 @@ export interface DesktopPreviewRecordingArtifact { createdAt: string; } +export const DesktopPreviewRecordingArtifactSchema: Schema.Codec = + Schema.Struct({ + id: Schema.String, + tabId: DesktopPreviewTabIdSchema, + path: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Int, + createdAt: Schema.String, + }); + export interface DesktopPreviewScreenshotArtifact { id: string; tabId: string; @@ -533,6 +626,16 @@ export interface DesktopPreviewScreenshotArtifact { createdAt: string; } +export const DesktopPreviewScreenshotArtifactSchema: Schema.Codec = + Schema.Struct({ + id: Schema.String, + tabId: DesktopPreviewTabIdSchema, + path: Schema.String, + mimeType: Schema.Literal("image/png"), + sizeBytes: Schema.Int, + createdAt: Schema.String, + }); + /** * Single stack frame captured by react-grab's `getElementContext`. We surface * the source file/line so coding agents can jump straight to the JSX that @@ -545,6 +648,13 @@ export interface PickedElementStackFrame { columnNumber: number | null; } +export const PickedElementStackFrameSchema: Schema.Codec = Schema.Struct({ + functionName: Schema.NullOr(Schema.String), + fileName: Schema.NullOr(Schema.String), + lineNumber: Schema.NullOr(Schema.Number), + columnNumber: Schema.NullOr(Schema.Number), +}); + /** * A successful element pick from the preview webview. All fields are * best-effort — pages that don't ship a React fiber tree (or aren't running @@ -574,6 +684,19 @@ export interface PickedElementPayload { pickedAt: string; } +export const PickedElementPayloadSchema: Schema.Codec = Schema.Struct({ + pageUrl: Schema.String, + pageTitle: Schema.NullOr(Schema.String), + tagName: Schema.String, + selector: Schema.NullOr(Schema.String), + htmlPreview: Schema.String, + componentName: Schema.NullOr(Schema.String), + source: Schema.NullOr(PickedElementStackFrameSchema), + stack: Schema.Array(PickedElementStackFrameSchema), + styles: Schema.String, + pickedAt: Schema.String, +}); + export interface PreviewAnnotationRect { x: number; y: number; @@ -581,22 +704,47 @@ export interface PreviewAnnotationRect { height: number; } +export const PreviewAnnotationRectSchema: Schema.Codec = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, +}); + export interface PreviewAnnotationPoint { x: number; y: number; } +export const PreviewAnnotationPointSchema: Schema.Codec = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, +}); + export interface PreviewAnnotationElementTarget { id: string; element: PickedElementPayload; rect: PreviewAnnotationRect; } +export const PreviewAnnotationElementTargetSchema: Schema.Codec = + Schema.Struct({ + id: Schema.String, + element: PickedElementPayloadSchema, + rect: PreviewAnnotationRectSchema, + }); + export interface PreviewAnnotationRegionTarget { id: string; rect: PreviewAnnotationRect; } +export const PreviewAnnotationRegionTargetSchema: Schema.Codec = + Schema.Struct({ + id: Schema.String, + rect: PreviewAnnotationRectSchema, + }); + export interface PreviewAnnotationStrokeTarget { id: string; color: string; @@ -605,6 +753,15 @@ export interface PreviewAnnotationStrokeTarget { bounds: PreviewAnnotationRect; } +export const PreviewAnnotationStrokeTargetSchema: Schema.Codec = + Schema.Struct({ + id: Schema.String, + color: Schema.String, + width: Schema.Number, + points: Schema.Array(PreviewAnnotationPointSchema), + bounds: PreviewAnnotationRectSchema, + }); + export interface PreviewAnnotationStyleChange { targetId: string; selector: string | null; @@ -613,6 +770,15 @@ export interface PreviewAnnotationStyleChange { value: string; } +export const PreviewAnnotationStyleChangeSchema: Schema.Codec = + Schema.Struct({ + targetId: Schema.String, + selector: Schema.NullOr(Schema.String), + property: Schema.String, + previousValue: Schema.String, + value: Schema.String, + }); + export interface PreviewAnnotationScreenshot { dataUrl: string; width: number; @@ -620,6 +786,14 @@ export interface PreviewAnnotationScreenshot { cropRect: PreviewAnnotationRect; } +export const PreviewAnnotationScreenshotSchema: Schema.Codec = + Schema.Struct({ + dataUrl: Schema.String, + width: Schema.Number, + height: Schema.Number, + cropRect: PreviewAnnotationRectSchema, + }); + /** * A submitted preview annotation. One annotation may reference multiple DOM * elements, freeform regions, and ink strokes. The desktop main process adds @@ -638,6 +812,83 @@ export interface PreviewAnnotationPayload { createdAt: string; } +export const PreviewAnnotationPayloadSchema: Schema.Codec = Schema.Struct( + { + id: Schema.String, + pageUrl: Schema.String, + pageTitle: Schema.NullOr(Schema.String), + comment: Schema.String, + elements: Schema.Array(PreviewAnnotationElementTargetSchema), + regions: Schema.Array(PreviewAnnotationRegionTargetSchema), + strokes: Schema.Array(PreviewAnnotationStrokeTargetSchema), + styleChanges: Schema.Array(PreviewAnnotationStyleChangeSchema), + screenshot: Schema.NullOr(PreviewAnnotationScreenshotSchema), + createdAt: Schema.String, + }, +); + +export const DesktopPreviewTabInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, +}); + +export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + webContentsId: Schema.Int.check(Schema.isGreaterThan(0)), +}); + +export const DesktopPreviewNavigateInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + url: Schema.String, +}); + +export const DesktopPreviewConfigInputSchema = Schema.Struct({ + environmentId: EnvironmentId, +}); + +export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({ + theme: DesktopPreviewAnnotationThemeSchema, +}); + +export const DesktopPreviewArtifactInputSchema = Schema.Struct({ + path: Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()), +}); + +export const DesktopPreviewRecordingSaveInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + mimeType: Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()), + data: Schema.Uint8Array, +}); + +export const DesktopPreviewAutomationClickInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationClickInput, +}); + +export const DesktopPreviewAutomationTypeInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationTypeInput, +}); + +export const DesktopPreviewAutomationPressInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationPressInput, +}); + +export const DesktopPreviewAutomationScrollInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationScrollInput, +}); + +export const DesktopPreviewAutomationEvaluateInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationEvaluateInput, +}); + +export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationWaitForInput, +}); + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;