From 3d9e8dea7d0d7d775bfc617929d7bb2c5d3e651e Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:17:00 -0700 Subject: [PATCH] Refactor desktop preview IPC onto shared manager - Move preview session and IPC wiring into the new preview module - Tighten IPC validation with schema-based handlers - Update preview asset paths and tests for the browser preview port --- .../scripts/build-preview-annotation-css.mjs | 6 +- apps/desktop/src/app/DesktopApp.ts | 2 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 13 +- apps/desktop/src/ipc/methods/preview.test.ts | 32 +- apps/desktop/src/ipc/methods/preview.ts | 567 +++++--- apps/desktop/src/main.ts | 12 +- apps/desktop/src/preview-pick-preload.ts | 1263 +---------------- .../Annotation.css} | 0 .../AnnotationStyles.generated.ts} | 0 apps/desktop/src/preview/BrowserSession.ts | 93 ++ apps/desktop/src/preview/GuestProtocol.ts | 6 + .../Manager.test.ts} | 12 +- .../Manager.ts} | 388 +++-- .../PickLabelPosition.ts} | 2 +- .../PickPreload.test.ts} | 2 +- apps/desktop/src/preview/PickPreload.ts | 1263 +++++++++++++++++ .../PickedElementPayload.test.ts} | 2 +- .../PickedElementPayload.ts} | 2 +- .../PlaywrightInjectedRuntime.test.ts} | 2 +- .../PlaywrightInjectedRuntime.ts} | 0 .../WebviewPreferences.test.ts} | 2 +- .../WebviewPreferences.ts} | 4 +- apps/desktop/src/window/DesktopWindow.test.ts | 7 + apps/desktop/src/window/DesktopWindow.ts | 15 +- apps/server/src/mcp/McpHttpServer.ts | 240 ++-- .../server/src/mcp/McpSessionRegistry.test.ts | 20 +- apps/server/src/mcp/McpSessionRegistry.ts | 7 +- .../src/mcp/PreviewAutomationBroker.test.ts | 6 +- .../server/src/mcp/PreviewAutomationBroker.ts | 27 +- apps/server/src/preview/Manager.ts | 4 +- apps/server/src/preview/PortScanner.test.ts | 113 +- apps/server/src/preview/PortScanner.ts | 92 +- packages/contracts/src/ipc.ts | 253 +++- 33 files changed, 2615 insertions(+), 1842 deletions(-) rename apps/desktop/src/{preview-annotation.css => preview/Annotation.css} (100%) rename apps/desktop/src/{preview-annotation-styles.generated.ts => preview/AnnotationStyles.generated.ts} (100%) create mode 100644 apps/desktop/src/preview/BrowserSession.ts create mode 100644 apps/desktop/src/preview/GuestProtocol.ts rename apps/desktop/src/{preview-view-manager.test.ts => preview/Manager.test.ts} (94%) rename apps/desktop/src/{preview-view-manager.ts => preview/Manager.ts} (80%) rename apps/desktop/src/{preview-pick-label-position.ts => preview/PickLabelPosition.ts} (95%) rename apps/desktop/src/{preview-pick-preload.test.ts => preview/PickPreload.test.ts} (97%) create mode 100644 apps/desktop/src/preview/PickPreload.ts rename apps/desktop/src/{picked-element-payload.test.ts => preview/PickedElementPayload.test.ts} (99%) rename apps/desktop/src/{picked-element-payload.ts => preview/PickedElementPayload.ts} (98%) rename apps/desktop/src/{playwright-injected-runtime.test.ts => preview/PlaywrightInjectedRuntime.test.ts} (94%) rename apps/desktop/src/{playwright-injected-runtime.ts => preview/PlaywrightInjectedRuntime.ts} (100%) rename apps/desktop/src/{preview-webview-preferences.test.ts => preview/WebviewPreferences.test.ts} (97%) rename apps/desktop/src/{preview-webview-preferences.ts => preview/WebviewPreferences.ts} (93%) 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/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-annotation.css b/apps/desktop/src/preview/Annotation.css similarity index 100% rename from apps/desktop/src/preview-annotation.css rename to apps/desktop/src/preview/Annotation.css diff --git a/apps/desktop/src/preview-annotation-styles.generated.ts b/apps/desktop/src/preview/AnnotationStyles.generated.ts similarity index 100% rename from apps/desktop/src/preview-annotation-styles.generated.ts rename to apps/desktop/src/preview/AnnotationStyles.generated.ts 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-view-manager.test.ts b/apps/desktop/src/preview/Manager.test.ts similarity index 94% rename from apps/desktop/src/preview-view-manager.test.ts rename to apps/desktop/src/preview/Manager.test.ts index a7aeabc0e0d6b63bcb31bd6ac7fe4d845ae78b16..7e07d556783dd125c860376b0343a597fb13e899 100644 --- a/apps/desktop/src/preview-view-manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -40,7 +40,7 @@ describe("PreviewViewManager automation status", () => { }); it("reports an unregistered webview as temporarily unavailable", async () => { - const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; const manager = new PreviewViewManager(); expect(manager.automationStatus("tab_1")).toEqual({ @@ -95,7 +95,7 @@ describe("PreviewViewManager automation status", () => { }, capturePage, } as never); - const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; const manager = new PreviewViewManager(); manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); manager.createTab("tab_1"); @@ -127,7 +127,7 @@ describe("PreviewViewManager automation status", () => { }); it("reveals only files inside the configured browser artifact directory", async () => { - const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; const manager = new PreviewViewManager(); manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); @@ -142,7 +142,7 @@ describe("PreviewViewManager automation status", () => { }); it("copies screenshot artifacts to the system clipboard", async () => { - const { PreviewViewManager } = await import("./preview-view-manager.ts"); + 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"; @@ -202,7 +202,7 @@ describe("PreviewViewManager automation status", () => { off: vi.fn(), }, } as never); - const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; const manager = new PreviewViewManager(); manager.onPointerEvent((event) => activity.push(event.phase)); manager.createTab("tab_1"); @@ -271,7 +271,7 @@ describe("PreviewViewManager automation status", () => { off: vi.fn(), }, } as never); - const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const { PreviewViewManager } = (await import("./Manager.ts")).__testing; const manager = new PreviewViewManager(); manager.createTab("tab_1"); manager.registerWebview("tab_1", 42); diff --git a/apps/desktop/src/preview-view-manager.ts b/apps/desktop/src/preview/Manager.ts similarity index 80% rename from apps/desktop/src/preview-view-manager.ts rename to apps/desktop/src/preview/Manager.ts index 5406ae731b05425211fd977181ab76cfa76083d1..5c62179bf6f87f34e1ffd77a77445b7fecfcaa7d 100644 --- a/apps/desktop/src/preview-view-manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -33,31 +33,30 @@ import { 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 * 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 { 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"; +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" } @@ -238,6 +237,7 @@ 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 { @@ -249,6 +249,11 @@ interface BrowserControlSession { readonly webContentsId: number; tail: Promise; initialized: Promise; + readonly onMessage: ( + event: Electron.Event, + method: string, + params: Record, + ) => void; } interface BrowserDiagnostics { @@ -318,13 +323,12 @@ const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): ); }; -export class PreviewViewManager { +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(); @@ -394,43 +398,6 @@ export class PreviewViewManager { 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; @@ -574,25 +541,6 @@ export class PreviewViewManager { 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, @@ -1289,20 +1237,15 @@ export class PreviewViewManager { "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) => { + const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { if (method === "Page.screencastFrame") { - const frame = params as Record; + const frame = params; const sessionId = frame["sessionId"]; if (typeof sessionId === "number") { void wc.debugger @@ -1325,8 +1268,15 @@ export class PreviewViewManager { for (const listener of this.recordingFrameListeners) listener(payload); } } - this.captureDiagnosticMessage(diagnostics, method, params as Record); - }); + 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([ @@ -1347,10 +1297,15 @@ export class PreviewViewManager { } 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() || !wc.debugger.isAttached()) return; + if (!wc || wc.isDestroyed()) return; + if (control) { + wc.debugger.off("message", control.onMessage); + } + if (!wc.debugger.isAttached()) return; try { wc.debugger.detach(); } catch { @@ -1617,7 +1572,7 @@ export class PreviewViewManager { // 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) => { + const beforeInput = (event: Electron.Event, input: Electron.Input): void => { if (this.isAppShortcut(input) && this.mainWindow && !this.mainWindow.isDestroyed()) { event.preventDefault(); this.mainWindow.webContents.sendInputEvent({ @@ -1631,9 +1586,10 @@ export class PreviewViewManager { ], }); } - }); + }; + wc.on("before-input-event", beforeInput); - this.attached.set(wc.id, { navigate: sync, failed, humanInput }); + this.attached.set(wc.id, { navigate: sync, failed, humanInput, beforeInput }); } private detachListeners(webContentsId: number): void { @@ -1648,6 +1604,7 @@ export class PreviewViewManager { 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); } @@ -1738,4 +1695,255 @@ export class PreviewWebviewNotInitializedError extends Error { } } -export const previewViewManager = new PreviewViewManager(); +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-pick-label-position.ts b/apps/desktop/src/preview/PickLabelPosition.ts similarity index 95% rename from apps/desktop/src/preview-pick-label-position.ts rename to apps/desktop/src/preview/PickLabelPosition.ts index e07f64f13d5cb371d0132c32f3838de2a4ed4ec6..cf7f3c811f882d85d7034ecab1f2499cd2c87a62 100644 --- a/apps/desktop/src/preview-pick-label-position.ts +++ b/apps/desktop/src/preview/PickLabelPosition.ts @@ -2,7 +2,7 @@ * 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 + * 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 diff --git a/apps/desktop/src/preview-pick-preload.test.ts b/apps/desktop/src/preview/PickPreload.test.ts similarity index 97% rename from apps/desktop/src/preview-pick-preload.test.ts rename to apps/desktop/src/preview/PickPreload.test.ts index 9f239e1d2e86a0cbecd1125bd99af4391d811c12..5696fe50812e98756596526d1e892522b4549649 100644 --- a/apps/desktop/src/preview-pick-preload.test.ts +++ b/apps/desktop/src/preview/PickPreload.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { computeLabelPosition } from "./preview-pick-label-position.ts"; +import { computeLabelPosition } from "./PickLabelPosition.ts"; const VIEWPORT = { viewportWidth: 1280, viewportHeight: 800 }; 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/picked-element-payload.test.ts b/apps/desktop/src/preview/PickedElementPayload.test.ts similarity index 99% rename from apps/desktop/src/picked-element-payload.test.ts rename to apps/desktop/src/preview/PickedElementPayload.test.ts index 39537fa7c967b634ab863cb365d49dd49ef25683..d7a967324771fa4cf8130d31e06b4aee9505b2c3 100644 --- a/apps/desktop/src/picked-element-payload.test.ts +++ b/apps/desktop/src/preview/PickedElementPayload.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { isPickedElementPayload, isPreviewAnnotationPayload } from "./picked-element-payload.ts"; +import { isPickedElementPayload, isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; function validPayload(overrides?: Record): Record { return { diff --git a/apps/desktop/src/picked-element-payload.ts b/apps/desktop/src/preview/PickedElementPayload.ts similarity index 98% rename from apps/desktop/src/picked-element-payload.ts rename to apps/desktop/src/preview/PickedElementPayload.ts index a9fadf63c8ff444fbc3c6b89edda07e83bd6dabb..e2d596120dba29d7914bea71a00e853bb2e20c44 100644 --- a/apps/desktop/src/picked-element-payload.ts +++ b/apps/desktop/src/preview/PickedElementPayload.ts @@ -1,6 +1,6 @@ /** * Strict structural validator for `PickedElementPayload` messages received - * from the in-page picker preload (`apps/desktop/src/preview-pick-preload.ts`) + * 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. * diff --git a/apps/desktop/src/playwright-injected-runtime.test.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts similarity index 94% rename from apps/desktop/src/playwright-injected-runtime.test.ts rename to apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts index 20692063672b4e535ccaa236733577f9eedc763b..02d80baf63c6bad083d32b89fbb25261b73c5f09 100644 --- a/apps/desktop/src/playwright-injected-runtime.test.ts +++ b/apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { playwrightInjectedRuntimeInstallExpression, playwrightInjectedRuntimeSource, -} from "./playwright-injected-runtime.ts"; +} from "./PlaywrightInjectedRuntime.ts"; describe("playwright injected runtime", () => { it("extracts the pinned runtime from playwright-core", async () => { diff --git a/apps/desktop/src/playwright-injected-runtime.ts b/apps/desktop/src/preview/PlaywrightInjectedRuntime.ts similarity index 100% rename from apps/desktop/src/playwright-injected-runtime.ts rename to apps/desktop/src/preview/PlaywrightInjectedRuntime.ts diff --git a/apps/desktop/src/preview-webview-preferences.test.ts b/apps/desktop/src/preview/WebviewPreferences.test.ts similarity index 97% rename from apps/desktop/src/preview-webview-preferences.test.ts rename to apps/desktop/src/preview/WebviewPreferences.test.ts index dc88714a51dc02b20f40e6aeb250e6a3418bae48..498c1df4665bade94cd1afd1188601280615cb66 100644 --- a/apps/desktop/src/preview-webview-preferences.test.ts +++ b/apps/desktop/src/preview/WebviewPreferences.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; +import { PREVIEW_WEBVIEW_PREFERENCES } from "./WebviewPreferences.ts"; /** * Mirrors Electron's webview attribute parser closely enough to catch the diff --git a/apps/desktop/src/preview-webview-preferences.ts b/apps/desktop/src/preview/WebviewPreferences.ts similarity index 93% rename from apps/desktop/src/preview-webview-preferences.ts rename to apps/desktop/src/preview/WebviewPreferences.ts index 2d96bff456c526a0696810474ccfa01678a6e415..085c75232b382b82c8791df510fd2843284f2c0f 100644 --- a/apps/desktop/src/preview-webview-preferences.ts +++ b/apps/desktop/src/preview/WebviewPreferences.ts @@ -4,7 +4,7 @@ * 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 + * without importing `Manager.ts` (which transitively imports * `electron` and blows up under vitest). * * - `contextIsolation=false`: the picker preload needs to share `globalThis` @@ -22,7 +22,7 @@ * - `nodeIntegration=false`: pinned for clarity (the page itself never gets * Node access). * - * Format notes (locked down by `preview-webview-preferences.test.ts`): + * 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. 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; -- 2.54.0