| author | |
| committer | |
| log | 3d9e8dea7d0d7d775bfc617929d7bb2c5d3e651e |
| tree | 01889d8955f8f648d8adef5cab9fb3ccfbb67a3c |
| parent | f7c422d5815e7be03415a87b30558c7bcf2cf8e9 |
- 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 port45 files changed, 5275 insertions(+), 4502 deletions(-)
apps/desktop/scripts/build-preview-annotation-css.mjs+3-3| ... | @@ -7,9 +7,9 @@ import { compile } from "tailwindcss"; | ... | @@ -7,9 +7,9 @@ import { compile } from "tailwindcss"; |
| 7 | 7 | ||
| 8 | const directory = dirname(fileURLToPath(import.meta.url)); | 8 | const directory = dirname(fileURLToPath(import.meta.url)); |
| 9 | const appRoot = join(directory, ".."); | 9 | const appRoot = join(directory, ".."); |
| 10 | const sourcePath = join(appRoot, "src", "preview-annotation.css"); | 10 | const sourcePath = join(appRoot, "src", "preview", "Annotation.css"); |
| 11 | const preloadPath = join(appRoot, "src", "preview-pick-preload.ts"); | 11 | const preloadPath = join(appRoot, "src", "preview", "PickPreload.ts"); |
| 12 | const outputPath = join(appRoot, "src", "preview-annotation-styles.generated.ts"); | 12 | const outputPath = join(appRoot, "src", "preview", "AnnotationStyles.generated.ts"); |
| 13 | const require = createRequire(import.meta.url); | 13 | const require = createRequire(import.meta.url); |
| 14 | const tailwindRoot = dirname(require.resolve("tailwindcss/package.json")); | 14 | const tailwindRoot = dirname(require.resolve("tailwindcss/package.json")); |
| 15 | 15 |
apps/desktop/src/app/DesktopApp.ts+1-1| ... | @@ -176,7 +176,7 @@ const bootstrap = Effect.gen(function* () { | ... | @@ -176,7 +176,7 @@ const bootstrap = Effect.gen(function* () { |
| 176 | ); | 176 | ); |
| 177 | } | 177 | } |
| 178 | 178 | ||
| 179 | yield* installDesktopIpcHandlers; | 179 | yield* installDesktopIpcHandlers(); |
| 180 | yield* logBootstrapInfo("bootstrap ipc handlers registered"); | 180 | yield* logBootstrapInfo("bootstrap ipc handlers registered"); |
| 181 | 181 | ||
| 182 | if (!(yield* Ref.get(state.quitting))) { | 182 | if (!(yield* Ref.get(state.quitting))) { |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+5-8| ... | @@ -1,7 +1,5 @@ | ... | @@ -1,7 +1,5 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | ||
| 3 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | ||
| 4 | import { previewViewManager } from "../preview-view-manager.ts"; | ||
| 5 | import * as DesktopIpc from "./DesktopIpc.ts"; | 3 | import * as DesktopIpc from "./DesktopIpc.ts"; |
| 6 | import { | 4 | import { |
| 7 | clearCloudAuthToken, | 5 | clearCloudAuthToken, |
| ... | @@ -50,12 +48,11 @@ import { | ... | @@ -50,12 +48,11 @@ import { |
| 50 | setTheme, | 48 | setTheme, |
| 51 | showContextMenu, | 49 | showContextMenu, |
| 52 | } from "./methods/window.ts"; | 50 | } from "./methods/window.ts"; |
| 53 | import { previewMethods } from "./methods/preview.ts"; | 51 | import * as PreviewIpc from "./methods/preview.ts"; |
| 54 | 52 | ||
| 55 | export const installDesktopIpcHandlers = Effect.gen(function* () { | 53 | export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { |
| 56 | const ipc = yield* DesktopIpc.DesktopIpc; | 54 | const ipc = yield* DesktopIpc.DesktopIpc; |
| 57 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | 55 | yield* PreviewIpc.installPreviewEventForwarding(); |
| 58 | previewViewManager.configureArtifactDirectory(environment.browserArtifactsDir); | ||
| 59 | 56 | ||
| 60 | yield* ipc.handleSync(getAppBranding); | 57 | yield* ipc.handleSync(getAppBranding); |
| 61 | yield* ipc.handleSync(getLocalEnvironmentBootstrap); | 58 | yield* ipc.handleSync(getLocalEnvironmentBootstrap); |
| ... | @@ -97,7 +94,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { | ... | @@ -97,7 +94,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { |
| 97 | yield* ipc.handle(downloadUpdate); | 94 | yield* ipc.handle(downloadUpdate); |
| 98 | yield* ipc.handle(installUpdate); | 95 | yield* ipc.handle(installUpdate); |
| 99 | yield* ipc.handle(checkForUpdate); | 96 | yield* ipc.handle(checkForUpdate); |
| 100 | for (const previewMethod of previewMethods) { | 97 | for (const previewMethod of PreviewIpc.methods) { |
| 101 | yield* ipc.handle(previewMethod); | 98 | yield* ipc.handle(previewMethod); |
| 102 | } | 99 | } |
| 103 | }).pipe(Effect.withSpan("desktop.ipc.installHandlers")); | 100 | }); |
apps/desktop/src/ipc/methods/preview.test.ts+29-3| ... | @@ -1,8 +1,19 @@ | ... | @@ -1,8 +1,19 @@ |
| 1 | import { it as effectIt } from "@effect/vitest"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 7 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 8 | ||
| 3 | const fromPartition = vi.fn(() => { | 9 | import * as PreviewManager from "../../preview/Manager.ts"; |
| 4 | throw new Error("Session can only be received when app is ready"); | 10 | import * as PreviewIpc from "./preview.ts"; |
| 5 | }); | 11 | |
| 12 | const { fromPartition } = vi.hoisted(() => ({ | ||
| 13 | fromPartition: vi.fn(() => { | ||
| 14 | throw new Error("Session can only be received when app is ready"); | ||
| 15 | }), | ||
| 16 | })); | ||
| 6 | 17 | ||
| 7 | vi.mock("electron", () => ({ | 18 | vi.mock("electron", () => ({ |
| 8 | BrowserWindow: { | 19 | BrowserWindow: { |
| ... | @@ -25,4 +36,19 @@ describe("preview IPC methods", () => { | ... | @@ -25,4 +36,19 @@ describe("preview IPC methods", () => { |
| 25 | await expect(import("./preview.ts")).resolves.toBeDefined(); | 36 | await expect(import("./preview.ts")).resolves.toBeDefined(); |
| 26 | expect(fromPartition).not.toHaveBeenCalled(); | 37 | expect(fromPartition).not.toHaveBeenCalled(); |
| 27 | }); | 38 | }); |
| 39 | |||
| 40 | effectIt.effect("rejects invalid webContents ids before resolving the preview service", () => | ||
| 41 | Effect.map( | ||
| 42 | PreviewIpc.registerWebview | ||
| 43 | .handler({ tabId: "tab-1", webContentsId: 0 }) | ||
| 44 | .pipe(Effect.provideService(PreviewManager.PreviewManager, null as never), Effect.exit), | ||
| 45 | (exit) => { | ||
| 46 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 47 | if (Exit.isSuccess(exit)) return; | ||
| 48 | const error = Cause.findErrorOption(exit.cause); | ||
| 49 | expect(Option.isSome(error) && Schema.isSchemaError(error.value)).toBe(true); | ||
| 50 | expect(fromPartition).not.toHaveBeenCalled(); | ||
| 51 | }, | ||
| 52 | ), | ||
| 53 | ); | ||
| 28 | }); | 54 | }); |
apps/desktop/src/ipc/methods/preview.ts+349-222| ... | @@ -1,251 +1,378 @@ | ... | @@ -1,251 +1,378 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as Data from "effect/Data"; | 2 | import { |
| 3 | import * as Effect from "effect/Effect"; | 3 | DesktopPreviewAnnotationThemeInputSchema, |
| 4 | import type { DesktopPreviewAnnotationTheme } from "@t3tools/contracts"; | 4 | DesktopPreviewArtifactInputSchema, |
| 5 | DesktopPreviewAutomationClickInputSchema, | ||
| 6 | DesktopPreviewAutomationEvaluateInputSchema, | ||
| 7 | DesktopPreviewAutomationPressInputSchema, | ||
| 8 | DesktopPreviewAutomationScrollInputSchema, | ||
| 9 | DesktopPreviewAutomationTypeInputSchema, | ||
| 10 | DesktopPreviewAutomationWaitForInputSchema, | ||
| 11 | DesktopPreviewConfigInputSchema, | ||
| 12 | DesktopPreviewNavigateInputSchema, | ||
| 13 | DesktopPreviewRecordingArtifactSchema, | ||
| 14 | DesktopPreviewRecordingSaveInputSchema, | ||
| 15 | DesktopPreviewRegisterWebviewInputSchema, | ||
| 16 | DesktopPreviewScreenshotArtifactSchema, | ||
| 17 | DesktopPreviewTabInputSchema, | ||
| 18 | DesktopPreviewWebviewConfigSchema, | ||
| 19 | PreviewAnnotationPayloadSchema, | ||
| 20 | PreviewAutomationSnapshot, | ||
| 21 | PreviewAutomationStatus, | ||
| 22 | } from "@t3tools/contracts"; | ||
| 5 | import { BrowserWindow } from "electron"; | 23 | import { BrowserWindow } from "electron"; |
| 24 | import * as Effect from "effect/Effect"; | ||
| 25 | import * as Schema from "effect/Schema"; | ||
| 6 | import { pathToFileURL } from "node:url"; | 26 | import { pathToFileURL } from "node:url"; |
| 7 | 27 | ||
| 8 | import { previewViewManager } from "../../preview-view-manager.ts"; | 28 | import * as PreviewManager from "../../preview/Manager.ts"; |
| 9 | import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview-webview-preferences.ts"; | 29 | import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; |
| 10 | import * as IpcChannels from "../channels.ts"; | 30 | import * as IpcChannels from "../channels.ts"; |
| 11 | import type { DesktopIpcMethod } from "../DesktopIpc.ts"; | 31 | import { makeIpcMethod } from "../DesktopIpc.ts"; |
| 12 | 32 | ||
| 13 | previewViewManager.onStateChange((tabId, state) => { | 33 | const broadcast = (channel: string, ...args: ReadonlyArray<unknown>): void => { |
| 14 | for (const window of BrowserWindow.getAllWindows()) { | 34 | for (const window of BrowserWindow.getAllWindows()) { |
| 15 | if (!window.isDestroyed()) { | 35 | if (!window.isDestroyed()) { |
| 16 | window.webContents.send(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state); | 36 | window.webContents.send(channel, ...args); |
| 17 | } | 37 | } |
| 18 | } | 38 | } |
| 19 | }); | 39 | }; |
| 20 | 40 | ||
| 21 | previewViewManager.onRecordingFrame((frame) => { | 41 | export const installPreviewEventForwarding = Effect.fn( |
| 22 | for (const window of BrowserWindow.getAllWindows()) { | 42 | "desktop.ipc.preview.installEventForwarding", |
| 23 | if (!window.isDestroyed()) { | 43 | )(function* () { |
| 24 | window.webContents.send(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame); | 44 | const manager = yield* PreviewManager.PreviewManager; |
| 25 | } | 45 | yield* manager.subscribeStateChanges((tabId, state) => { |
| 26 | } | 46 | broadcast(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state); |
| 47 | }); | ||
| 48 | yield* manager.subscribeRecordingFrames((frame) => { | ||
| 49 | broadcast(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame); | ||
| 50 | }); | ||
| 51 | yield* manager.subscribePointerEvents((event) => { | ||
| 52 | broadcast(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event); | ||
| 53 | }); | ||
| 27 | }); | 54 | }); |
| 28 | 55 | ||
| 29 | previewViewManager.onPointerEvent((event) => { | 56 | export const createTab = makeIpcMethod({ |
| 30 | for (const window of BrowserWindow.getAllWindows()) { | 57 | channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, |
| 31 | if (!window.isDestroyed()) { | 58 | payload: DesktopPreviewTabInputSchema, |
| 32 | window.webContents.send(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event); | 59 | result: Schema.Void, |
| 33 | } | 60 | handler: Effect.fn("desktop.ipc.preview.createTab")(function* ({ tabId }) { |
| 34 | } | 61 | const manager = yield* PreviewManager.PreviewManager; |
| 62 | yield* manager.createTab(tabId); | ||
| 63 | }), | ||
| 35 | }); | 64 | }); |
| 36 | 65 | ||
| 37 | const tabIdFrom = (raw: unknown): string => { | 66 | export const closeTab = makeIpcMethod({ |
| 38 | if (typeof raw !== "object" || raw === null || !("tabId" in raw)) { | 67 | channel: IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, |
| 39 | throw new Error("preview tab id is required"); | 68 | payload: DesktopPreviewTabInputSchema, |
| 40 | } | 69 | result: Schema.Void, |
| 41 | const tabId = raw.tabId; | 70 | handler: Effect.fn("desktop.ipc.preview.closeTab")(function* ({ tabId }) { |
| 42 | if (typeof tabId !== "string" || tabId.trim().length === 0) { | 71 | const manager = yield* PreviewManager.PreviewManager; |
| 43 | throw new Error("preview tab id must be a non-empty string"); | 72 | yield* manager.closeTab(tabId); |
| 44 | } | 73 | }), |
| 45 | return tabId; | 74 | }); |
| 46 | }; | ||
| 47 | |||
| 48 | const inputFrom = (raw: unknown): unknown => { | ||
| 49 | if (typeof raw !== "object" || raw === null || !("input" in raw)) { | ||
| 50 | throw new Error("preview automation input is required"); | ||
| 51 | } | ||
| 52 | return raw.input; | ||
| 53 | }; | ||
| 54 | 75 | ||
| 55 | const annotationThemeFrom = (raw: unknown): DesktopPreviewAnnotationTheme => { | 76 | export const registerWebview = makeIpcMethod({ |
| 56 | if (typeof raw !== "object" || raw === null || !("theme" in raw)) { | 77 | channel: IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, |
| 57 | throw new Error("preview annotation theme is required"); | 78 | payload: DesktopPreviewRegisterWebviewInputSchema, |
| 58 | } | 79 | result: Schema.Void, |
| 59 | const theme = raw.theme; | 80 | handler: Effect.fn("desktop.ipc.preview.registerWebview")(function* ({ tabId, webContentsId }) { |
| 60 | if (typeof theme !== "object" || theme === null) { | 81 | const manager = yield* PreviewManager.PreviewManager; |
| 61 | throw new Error("preview annotation theme must be an object"); | 82 | yield* manager.registerWebview(tabId, webContentsId); |
| 62 | } | 83 | }), |
| 63 | const record = theme as Record<string, unknown>; | 84 | }); |
| 64 | const stringKeys = [ | ||
| 65 | "radius", | ||
| 66 | "background", | ||
| 67 | "foreground", | ||
| 68 | "popover", | ||
| 69 | "popoverForeground", | ||
| 70 | "primary", | ||
| 71 | "primaryForeground", | ||
| 72 | "muted", | ||
| 73 | "mutedForeground", | ||
| 74 | "accent", | ||
| 75 | "accentForeground", | ||
| 76 | "border", | ||
| 77 | "input", | ||
| 78 | "ring", | ||
| 79 | "fontSans", | ||
| 80 | "fontMono", | ||
| 81 | ] as const; | ||
| 82 | for (const key of stringKeys) { | ||
| 83 | if (typeof record[key] !== "string" || record[key].length === 0) { | ||
| 84 | throw new Error(`preview annotation theme ${key} must be a non-empty string`); | ||
| 85 | } | ||
| 86 | } | ||
| 87 | if (record["colorScheme"] !== "light" && record["colorScheme"] !== "dark") { | ||
| 88 | throw new Error("preview annotation theme colorScheme must be light or dark"); | ||
| 89 | } | ||
| 90 | return record as unknown as DesktopPreviewAnnotationTheme; | ||
| 91 | }; | ||
| 92 | 85 | ||
| 93 | class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{ | 86 | export const navigate = makeIpcMethod({ |
| 94 | readonly cause: unknown; | 87 | channel: IpcChannels.PREVIEW_NAVIGATE_CHANNEL, |
| 95 | }> {} | 88 | payload: DesktopPreviewNavigateInputSchema, |
| 89 | result: Schema.Void, | ||
| 90 | handler: Effect.fn("desktop.ipc.preview.navigate")(function* ({ tabId, url }) { | ||
| 91 | const manager = yield* PreviewManager.PreviewManager; | ||
| 92 | yield* manager.navigate(tabId, url); | ||
| 93 | }), | ||
| 94 | }); | ||
| 96 | 95 | ||
| 97 | const method = ( | 96 | const tabMethod = ( |
| 98 | channel: string, | 97 | channel: string, |
| 99 | handler: (raw: unknown) => unknown | Promise<unknown>, | 98 | name: string, |
| 100 | ): DesktopIpcMethod<PreviewIpcError, never> => ({ | 99 | invoke: ( |
| 101 | channel, | 100 | manager: PreviewManager.PreviewManagerShape, |
| 102 | handler: (raw) => | 101 | tabId: string, |
| 103 | Effect.tryPromise({ | 102 | ) => Effect.Effect<void, PreviewManager.PreviewManagerError>, |
| 104 | try: () => Promise.resolve(handler(raw)), | 103 | ) => |
| 105 | catch: (cause) => new PreviewIpcError({ cause }), | 104 | makeIpcMethod({ |
| 105 | channel, | ||
| 106 | payload: DesktopPreviewTabInputSchema, | ||
| 107 | result: Schema.Void, | ||
| 108 | handler: Effect.fn(name)(function* ({ tabId }) { | ||
| 109 | const manager = yield* PreviewManager.PreviewManager; | ||
| 110 | yield* invoke(manager, tabId); | ||
| 106 | }), | 111 | }), |
| 112 | }); | ||
| 113 | |||
| 114 | export const goBack = tabMethod( | ||
| 115 | IpcChannels.PREVIEW_GO_BACK_CHANNEL, | ||
| 116 | "desktop.ipc.preview.goBack", | ||
| 117 | (manager, tabId) => manager.goBack(tabId), | ||
| 118 | ); | ||
| 119 | export const goForward = tabMethod( | ||
| 120 | IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, | ||
| 121 | "desktop.ipc.preview.goForward", | ||
| 122 | (manager, tabId) => manager.goForward(tabId), | ||
| 123 | ); | ||
| 124 | export const refresh = tabMethod( | ||
| 125 | IpcChannels.PREVIEW_REFRESH_CHANNEL, | ||
| 126 | "desktop.ipc.preview.refresh", | ||
| 127 | (manager, tabId) => manager.refresh(tabId), | ||
| 128 | ); | ||
| 129 | export const zoomIn = tabMethod( | ||
| 130 | IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, | ||
| 131 | "desktop.ipc.preview.zoomIn", | ||
| 132 | (manager, tabId) => manager.zoomIn(tabId), | ||
| 133 | ); | ||
| 134 | export const zoomOut = tabMethod( | ||
| 135 | IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, | ||
| 136 | "desktop.ipc.preview.zoomOut", | ||
| 137 | (manager, tabId) => manager.zoomOut(tabId), | ||
| 138 | ); | ||
| 139 | export const resetZoom = tabMethod( | ||
| 140 | IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, | ||
| 141 | "desktop.ipc.preview.resetZoom", | ||
| 142 | (manager, tabId) => manager.resetZoom(tabId), | ||
| 143 | ); | ||
| 144 | export const hardReload = tabMethod( | ||
| 145 | IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, | ||
| 146 | "desktop.ipc.preview.hardReload", | ||
| 147 | (manager, tabId) => manager.hardReload(tabId), | ||
| 148 | ); | ||
| 149 | export const openDevTools = tabMethod( | ||
| 150 | IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, | ||
| 151 | "desktop.ipc.preview.openDevTools", | ||
| 152 | (manager, tabId) => manager.openDevTools(tabId), | ||
| 153 | ); | ||
| 154 | export const cancelPickElement = tabMethod( | ||
| 155 | IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, | ||
| 156 | "desktop.ipc.preview.cancelPickElement", | ||
| 157 | (manager, tabId) => manager.cancelPickElement(tabId), | ||
| 158 | ); | ||
| 159 | export const startRecording = tabMethod( | ||
| 160 | IpcChannels.PREVIEW_RECORDING_START_CHANNEL, | ||
| 161 | "desktop.ipc.preview.startRecording", | ||
| 162 | (manager, tabId) => manager.startRecording(tabId), | ||
| 163 | ); | ||
| 164 | export const stopRecording = tabMethod( | ||
| 165 | IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, | ||
| 166 | "desktop.ipc.preview.stopRecording", | ||
| 167 | (manager, tabId) => manager.stopRecording(tabId), | ||
| 168 | ); | ||
| 169 | |||
| 170 | export const clearCookies = makeIpcMethod({ | ||
| 171 | channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, | ||
| 172 | payload: Schema.Void, | ||
| 173 | result: Schema.Void, | ||
| 174 | handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* () { | ||
| 175 | const manager = yield* PreviewManager.PreviewManager; | ||
| 176 | yield* manager.clearCookies(); | ||
| 177 | }), | ||
| 107 | }); | 178 | }); |
| 108 | 179 | ||
| 109 | export const previewMethods = [ | 180 | export const clearCache = makeIpcMethod({ |
| 110 | method(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, (raw) => | 181 | channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, |
| 111 | previewViewManager.createTab(tabIdFrom(raw)), | 182 | payload: Schema.Void, |
| 112 | ), | 183 | result: Schema.Void, |
| 113 | method(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, (raw) => | 184 | handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () { |
| 114 | previewViewManager.closeTab(tabIdFrom(raw)), | 185 | const manager = yield* PreviewManager.PreviewManager; |
| 115 | ), | 186 | yield* manager.clearCache(); |
| 116 | method(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, (raw) => { | 187 | }), |
| 117 | const tabId = tabIdFrom(raw); | 188 | }); |
| 118 | const webContentsId = | 189 | |
| 119 | typeof raw === "object" && raw !== null && "webContentsId" in raw ? raw.webContentsId : null; | 190 | export const getPreviewConfig = makeIpcMethod({ |
| 120 | if ( | 191 | channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, |
| 121 | typeof webContentsId !== "number" || | 192 | payload: DesktopPreviewConfigInputSchema, |
| 122 | !Number.isInteger(webContentsId) || | 193 | result: DesktopPreviewWebviewConfigSchema, |
| 123 | webContentsId <= 0 | 194 | handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId }) { |
| 124 | ) { | 195 | const manager = yield* PreviewManager.PreviewManager; |
| 125 | throw new Error("preview webContentsId must be a positive integer"); | 196 | yield* manager.getBrowserSession(environmentId); |
| 126 | } | ||
| 127 | return previewViewManager.registerWebview(tabId, webContentsId); | ||
| 128 | }), | ||
| 129 | method(IpcChannels.PREVIEW_NAVIGATE_CHANNEL, (raw) => { | ||
| 130 | const tabId = tabIdFrom(raw); | ||
| 131 | const url = typeof raw === "object" && raw !== null && "url" in raw ? raw.url : null; | ||
| 132 | if (typeof url !== "string") throw new Error("preview url must be a string"); | ||
| 133 | return previewViewManager.navigate(tabId, url); | ||
| 134 | }), | ||
| 135 | method(IpcChannels.PREVIEW_GO_BACK_CHANNEL, (raw) => previewViewManager.goBack(tabIdFrom(raw))), | ||
| 136 | method(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, (raw) => | ||
| 137 | previewViewManager.goForward(tabIdFrom(raw)), | ||
| 138 | ), | ||
| 139 | method(IpcChannels.PREVIEW_REFRESH_CHANNEL, (raw) => previewViewManager.refresh(tabIdFrom(raw))), | ||
| 140 | method(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, (raw) => previewViewManager.zoomIn(tabIdFrom(raw))), | ||
| 141 | method(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, (raw) => previewViewManager.zoomOut(tabIdFrom(raw))), | ||
| 142 | method(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, (raw) => | ||
| 143 | previewViewManager.resetZoom(tabIdFrom(raw)), | ||
| 144 | ), | ||
| 145 | method(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, (raw) => | ||
| 146 | previewViewManager.hardReload(tabIdFrom(raw)), | ||
| 147 | ), | ||
| 148 | method(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, (raw) => | ||
| 149 | previewViewManager.openDevTools(tabIdFrom(raw)), | ||
| 150 | ), | ||
| 151 | method(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, () => previewViewManager.clearCookies()), | ||
| 152 | method(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, () => previewViewManager.clearCache()), | ||
| 153 | method(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, (raw) => { | ||
| 154 | const environmentId = | ||
| 155 | typeof raw === "object" && raw !== null && "environmentId" in raw ? raw.environmentId : null; | ||
| 156 | if (typeof environmentId !== "string" || environmentId.length === 0) { | ||
| 157 | throw new Error("preview environment id is required"); | ||
| 158 | } | ||
| 159 | previewViewManager.getBrowserSession(environmentId); | ||
| 160 | const preloadPath = `${__dirname}/preview-pick-preload.cjs`; | ||
| 161 | return { | 197 | return { |
| 162 | partition: previewViewManager.getBrowserPartition(environmentId), | 198 | partition: manager.getBrowserPartition(environmentId), |
| 163 | webPreferences: PREVIEW_WEBVIEW_PREFERENCES, | 199 | webPreferences: PREVIEW_WEBVIEW_PREFERENCES, |
| 164 | preloadUrl: pathToFileURL(preloadPath).href, | 200 | preloadUrl: pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href, |
| 165 | }; | 201 | }; |
| 166 | }), | 202 | }), |
| 167 | method(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, (raw) => | 203 | }); |
| 168 | previewViewManager.setAnnotationTheme(annotationThemeFrom(raw)), | 204 | |
| 169 | ), | 205 | export const setAnnotationTheme = makeIpcMethod({ |
| 170 | method(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, (raw) => | 206 | channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, |
| 171 | previewViewManager.pickElement(tabIdFrom(raw)), | 207 | payload: DesktopPreviewAnnotationThemeInputSchema, |
| 172 | ), | 208 | result: Schema.Void, |
| 173 | method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) => | 209 | handler: Effect.fn("desktop.ipc.preview.setAnnotationTheme")(function* ({ theme }) { |
| 174 | previewViewManager.cancelPickElement(tabIdFrom(raw)), | 210 | const manager = yield* PreviewManager.PreviewManager; |
| 175 | ), | 211 | yield* manager.setAnnotationTheme(theme); |
| 176 | method(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, (raw) => | ||
| 177 | previewViewManager.captureScreenshot(tabIdFrom(raw)), | ||
| 178 | ), | ||
| 179 | method(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, (raw) => { | ||
| 180 | const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null; | ||
| 181 | if (typeof path !== "string" || path.trim().length === 0) { | ||
| 182 | throw new Error("preview artifact path is required"); | ||
| 183 | } | ||
| 184 | return previewViewManager.revealArtifact(path); | ||
| 185 | }), | 212 | }), |
| 186 | method(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, (raw) => { | 213 | }); |
| 187 | const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null; | 214 | |
| 188 | if (typeof path !== "string" || path.trim().length === 0) { | 215 | export const pickElement = makeIpcMethod({ |
| 189 | throw new Error("preview artifact path is required"); | 216 | channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, |
| 190 | } | 217 | payload: DesktopPreviewTabInputSchema, |
| 191 | return previewViewManager.copyArtifactToClipboard(path); | 218 | result: Schema.NullOr(PreviewAnnotationPayloadSchema), |
| 192 | }), | 219 | handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) { |
| 193 | method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) => | 220 | const manager = yield* PreviewManager.PreviewManager; |
| 194 | previewViewManager.automationStatus(tabIdFrom(raw)), | 221 | return yield* manager.pickElement(tabId); |
| 195 | ), | 222 | }), |
| 196 | method(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, (raw) => | 223 | }); |
| 197 | previewViewManager.automationSnapshot(tabIdFrom(raw)), | 224 | |
| 198 | ), | 225 | export const captureScreenshot = makeIpcMethod({ |
| 199 | method(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, (raw) => | 226 | channel: IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, |
| 200 | previewViewManager.automationClick( | 227 | payload: DesktopPreviewTabInputSchema, |
| 201 | tabIdFrom(raw), | 228 | result: DesktopPreviewScreenshotArtifactSchema, |
| 202 | inputFrom(raw) as Parameters<typeof previewViewManager.automationClick>[1], | 229 | handler: Effect.fn("desktop.ipc.preview.captureScreenshot")(function* ({ tabId }) { |
| 203 | ), | 230 | const manager = yield* PreviewManager.PreviewManager; |
| 204 | ), | 231 | return yield* manager.captureScreenshot(tabId); |
| 205 | method(IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, (raw) => | 232 | }), |
| 206 | previewViewManager.automationType( | 233 | }); |
| 207 | tabIdFrom(raw), | 234 | |
| 208 | inputFrom(raw) as Parameters<typeof previewViewManager.automationType>[1], | 235 | export const revealArtifact = makeIpcMethod({ |
| 209 | ), | 236 | channel: IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, |
| 210 | ), | 237 | payload: DesktopPreviewArtifactInputSchema, |
| 211 | method(IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, (raw) => | 238 | result: Schema.Void, |
| 212 | previewViewManager.automationPress( | 239 | handler: Effect.fn("desktop.ipc.preview.revealArtifact")(function* ({ path }) { |
| 213 | tabIdFrom(raw), | 240 | const manager = yield* PreviewManager.PreviewManager; |
| 214 | inputFrom(raw) as Parameters<typeof previewViewManager.automationPress>[1], | 241 | yield* manager.revealArtifact(path); |
| 215 | ), | 242 | }), |
| 216 | ), | 243 | }); |
| 217 | method(IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, (raw) => | 244 | |
| 218 | previewViewManager.automationScroll( | 245 | export const copyArtifactToClipboard = makeIpcMethod({ |
| 219 | tabIdFrom(raw), | 246 | channel: IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, |
| 220 | inputFrom(raw) as Parameters<typeof previewViewManager.automationScroll>[1], | 247 | payload: DesktopPreviewArtifactInputSchema, |
| 221 | ), | 248 | result: Schema.Void, |
| 222 | ), | 249 | handler: Effect.fn("desktop.ipc.preview.copyArtifactToClipboard")(function* ({ path }) { |
| 223 | method(IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, (raw) => | 250 | const manager = yield* PreviewManager.PreviewManager; |
| 224 | previewViewManager.automationEvaluate( | 251 | yield* manager.copyArtifactToClipboard(path); |
| 225 | tabIdFrom(raw), | 252 | }), |
| 226 | inputFrom(raw) as Parameters<typeof previewViewManager.automationEvaluate>[1], | 253 | }); |
| 227 | ), | 254 | |
| 228 | ), | 255 | export const automationStatus = makeIpcMethod({ |
| 229 | method(IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, (raw) => | 256 | channel: IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, |
| 230 | previewViewManager.automationWaitFor( | 257 | payload: DesktopPreviewTabInputSchema, |
| 231 | tabIdFrom(raw), | 258 | result: PreviewAutomationStatus, |
| 232 | inputFrom(raw) as Parameters<typeof previewViewManager.automationWaitFor>[1], | 259 | handler: Effect.fn("desktop.ipc.preview.automationStatus")(function* ({ tabId }) { |
| 233 | ), | 260 | const manager = yield* PreviewManager.PreviewManager; |
| 234 | ), | 261 | return yield* manager.automationStatus(tabId); |
| 235 | method(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, (raw) => | 262 | }), |
| 236 | previewViewManager.startRecording(tabIdFrom(raw)), | 263 | }); |
| 237 | ), | 264 | |
| 238 | method(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, (raw) => | 265 | export const automationSnapshot = makeIpcMethod({ |
| 239 | previewViewManager.stopRecording(tabIdFrom(raw)), | 266 | channel: IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, |
| 240 | ), | 267 | payload: DesktopPreviewTabInputSchema, |
| 241 | method(IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, (raw) => { | 268 | result: PreviewAutomationSnapshot, |
| 242 | const tabId = tabIdFrom(raw); | 269 | handler: Effect.fn("desktop.ipc.preview.automationSnapshot")(function* ({ tabId }) { |
| 243 | if (typeof raw !== "object" || raw === null) throw new Error("recording payload is required"); | 270 | const manager = yield* PreviewManager.PreviewManager; |
| 244 | const mimeType = "mimeType" in raw ? raw.mimeType : null; | 271 | return yield* manager.automationSnapshot(tabId); |
| 245 | const data = "data" in raw ? raw.data : null; | 272 | }), |
| 246 | if (typeof mimeType !== "string" || !(data instanceof Uint8Array)) { | 273 | }); |
| 247 | throw new Error("recording mimeType and bytes are required"); | 274 | |
| 248 | } | 275 | export const automationClick = makeIpcMethod({ |
| 249 | return previewViewManager.saveRecording(tabId, mimeType, data); | 276 | channel: IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, |
| 277 | payload: DesktopPreviewAutomationClickInputSchema, | ||
| 278 | result: Schema.Void, | ||
| 279 | handler: Effect.fn("desktop.ipc.preview.automationClick")(function* ({ tabId, input }) { | ||
| 280 | const manager = yield* PreviewManager.PreviewManager; | ||
| 281 | yield* manager.automationClick(tabId, input); | ||
| 282 | }), | ||
| 283 | }); | ||
| 284 | |||
| 285 | export const automationType = makeIpcMethod({ | ||
| 286 | channel: IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, | ||
| 287 | payload: DesktopPreviewAutomationTypeInputSchema, | ||
| 288 | result: Schema.Void, | ||
| 289 | handler: Effect.fn("desktop.ipc.preview.automationType")(function* ({ tabId, input }) { | ||
| 290 | const manager = yield* PreviewManager.PreviewManager; | ||
| 291 | yield* manager.automationType(tabId, input); | ||
| 250 | }), | 292 | }), |
| 293 | }); | ||
| 294 | |||
| 295 | export const automationPress = makeIpcMethod({ | ||
| 296 | channel: IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, | ||
| 297 | payload: DesktopPreviewAutomationPressInputSchema, | ||
| 298 | result: Schema.Void, | ||
| 299 | handler: Effect.fn("desktop.ipc.preview.automationPress")(function* ({ tabId, input }) { | ||
| 300 | const manager = yield* PreviewManager.PreviewManager; | ||
| 301 | yield* manager.automationPress(tabId, input); | ||
| 302 | }), | ||
| 303 | }); | ||
| 304 | |||
| 305 | export const automationScroll = makeIpcMethod({ | ||
| 306 | channel: IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, | ||
| 307 | payload: DesktopPreviewAutomationScrollInputSchema, | ||
| 308 | result: Schema.Void, | ||
| 309 | handler: Effect.fn("desktop.ipc.preview.automationScroll")(function* ({ tabId, input }) { | ||
| 310 | const manager = yield* PreviewManager.PreviewManager; | ||
| 311 | yield* manager.automationScroll(tabId, input); | ||
| 312 | }), | ||
| 313 | }); | ||
| 314 | |||
| 315 | export const automationEvaluate = makeIpcMethod({ | ||
| 316 | channel: IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, | ||
| 317 | payload: DesktopPreviewAutomationEvaluateInputSchema, | ||
| 318 | result: Schema.Unknown, | ||
| 319 | handler: Effect.fn("desktop.ipc.preview.automationEvaluate")(function* ({ tabId, input }) { | ||
| 320 | const manager = yield* PreviewManager.PreviewManager; | ||
| 321 | return yield* manager.automationEvaluate(tabId, input); | ||
| 322 | }), | ||
| 323 | }); | ||
| 324 | |||
| 325 | export const automationWaitFor = makeIpcMethod({ | ||
| 326 | channel: IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, | ||
| 327 | payload: DesktopPreviewAutomationWaitForInputSchema, | ||
| 328 | result: Schema.Void, | ||
| 329 | handler: Effect.fn("desktop.ipc.preview.automationWaitFor")(function* ({ tabId, input }) { | ||
| 330 | const manager = yield* PreviewManager.PreviewManager; | ||
| 331 | yield* manager.automationWaitFor(tabId, input); | ||
| 332 | }), | ||
| 333 | }); | ||
| 334 | |||
| 335 | export const saveRecording = makeIpcMethod({ | ||
| 336 | channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, | ||
| 337 | payload: DesktopPreviewRecordingSaveInputSchema, | ||
| 338 | result: DesktopPreviewRecordingArtifactSchema, | ||
| 339 | handler: Effect.fn("desktop.ipc.preview.saveRecording")(function* ({ tabId, mimeType, data }) { | ||
| 340 | const manager = yield* PreviewManager.PreviewManager; | ||
| 341 | return yield* manager.saveRecording(tabId, mimeType, data); | ||
| 342 | }), | ||
| 343 | }); | ||
| 344 | |||
| 345 | export const methods = [ | ||
| 346 | createTab, | ||
| 347 | closeTab, | ||
| 348 | registerWebview, | ||
| 349 | navigate, | ||
| 350 | goBack, | ||
| 351 | goForward, | ||
| 352 | refresh, | ||
| 353 | zoomIn, | ||
| 354 | zoomOut, | ||
| 355 | resetZoom, | ||
| 356 | hardReload, | ||
| 357 | openDevTools, | ||
| 358 | clearCookies, | ||
| 359 | clearCache, | ||
| 360 | getPreviewConfig, | ||
| 361 | setAnnotationTheme, | ||
| 362 | pickElement, | ||
| 363 | cancelPickElement, | ||
| 364 | captureScreenshot, | ||
| 365 | revealArtifact, | ||
| 366 | copyArtifactToClipboard, | ||
| 367 | automationStatus, | ||
| 368 | automationSnapshot, | ||
| 369 | automationClick, | ||
| 370 | automationType, | ||
| 371 | automationPress, | ||
| 372 | automationScroll, | ||
| 373 | automationEvaluate, | ||
| 374 | automationWaitFor, | ||
| 375 | startRecording, | ||
| 376 | stopRecording, | ||
| 377 | saveRecording, | ||
| 251 | ] as const; | 378 | ] as const; |
apps/desktop/src/main.ts+11-1| ... | @@ -44,6 +44,8 @@ import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; | ... | @@ -44,6 +44,8 @@ import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; |
| 44 | import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; | 44 | import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; |
| 45 | import * as DesktopState from "./app/DesktopState.ts"; | 45 | import * as DesktopState from "./app/DesktopState.ts"; |
| 46 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; | 46 | import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; |
| 47 | import * as PreviewBrowserSession from "./preview/BrowserSession.ts"; | ||
| 48 | import * as PreviewManager from "./preview/Manager.ts"; | ||
| 47 | import * as DesktopWindow from "./window/DesktopWindow.ts"; | 49 | import * as DesktopWindow from "./window/DesktopWindow.ts"; |
| 48 | 50 | ||
| 49 | const desktopEnvironmentLayer = Layer.unwrap( | 51 | const desktopEnvironmentLayer = Layer.unwrap( |
| ... | @@ -127,7 +129,15 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( | ... | @@ -127,7 +129,15 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( |
| 127 | Layer.provideMerge(desktopFoundationLayer), | 129 | Layer.provideMerge(desktopFoundationLayer), |
| 128 | ); | 130 | ); |
| 129 | 131 | ||
| 130 | const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopServerExposureLayer)); | 132 | const desktopPreviewLayer = PreviewManager.layer.pipe( |
| 133 | Layer.provideMerge(PreviewBrowserSession.layer), | ||
| 134 | Layer.provideMerge(desktopFoundationLayer), | ||
| 135 | ); | ||
| 136 | |||
| 137 | const desktopWindowLayer = DesktopWindow.layer.pipe( | ||
| 138 | Layer.provideMerge(desktopServerExposureLayer), | ||
| 139 | Layer.provideMerge(desktopPreviewLayer), | ||
| 140 | ); | ||
| 131 | 141 | ||
| 132 | const desktopBackendLayer = DesktopBackendManager.layer.pipe( | 142 | const desktopBackendLayer = DesktopBackendManager.layer.pipe( |
| 133 | Layer.provideMerge(DesktopAppIdentity.layer), | 143 | Layer.provideMerge(DesktopAppIdentity.layer), |
apps/desktop/src/picked-element-payload.test.ts deleted-201| ... | @@ -1,201 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { isPickedElementPayload, isPreviewAnnotationPayload } from "./picked-element-payload.ts"; | ||
| 4 | |||
| 5 | function validPayload(overrides?: Record<string, unknown>): Record<string, unknown> { | ||
| 6 | return { | ||
| 7 | pageUrl: "https://example.com/", | ||
| 8 | pageTitle: "Example", | ||
| 9 | tagName: "button", | ||
| 10 | selector: "button.submit", | ||
| 11 | htmlPreview: "<button>Save</button>", | ||
| 12 | componentName: "SubmitButton", | ||
| 13 | source: { | ||
| 14 | functionName: "SubmitButton", | ||
| 15 | fileName: "/repo/src/Button.tsx", | ||
| 16 | lineNumber: 12, | ||
| 17 | columnNumber: 5, | ||
| 18 | }, | ||
| 19 | stack: [ | ||
| 20 | { | ||
| 21 | functionName: "SubmitButton", | ||
| 22 | fileName: "/repo/src/Button.tsx", | ||
| 23 | lineNumber: 12, | ||
| 24 | columnNumber: 5, | ||
| 25 | }, | ||
| 26 | ], | ||
| 27 | styles: ".submit { color: white; }", | ||
| 28 | pickedAt: "2026-05-03T18:00:00.000Z", | ||
| 29 | ...overrides, | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | |||
| 33 | describe("isPickedElementPayload", () => { | ||
| 34 | it("accepts a complete, well-typed payload", () => { | ||
| 35 | expect(isPickedElementPayload(validPayload())).toBe(true); | ||
| 36 | }); | ||
| 37 | |||
| 38 | it("accepts nullable string fields when null", () => { | ||
| 39 | expect( | ||
| 40 | isPickedElementPayload( | ||
| 41 | validPayload({ pageTitle: null, selector: null, componentName: null, source: null }), | ||
| 42 | ), | ||
| 43 | ).toBe(true); | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("accepts an empty stack array", () => { | ||
| 47 | expect(isPickedElementPayload(validPayload({ stack: [] }))).toBe(true); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("accepts stack frames with null fields", () => { | ||
| 51 | expect( | ||
| 52 | isPickedElementPayload( | ||
| 53 | validPayload({ | ||
| 54 | stack: [ | ||
| 55 | { | ||
| 56 | functionName: null, | ||
| 57 | fileName: null, | ||
| 58 | lineNumber: null, | ||
| 59 | columnNumber: null, | ||
| 60 | }, | ||
| 61 | ], | ||
| 62 | }), | ||
| 63 | ), | ||
| 64 | ).toBe(true); | ||
| 65 | }); | ||
| 66 | |||
| 67 | it("rejects null and primitive inputs", () => { | ||
| 68 | expect(isPickedElementPayload(null)).toBe(false); | ||
| 69 | expect(isPickedElementPayload(undefined)).toBe(false); | ||
| 70 | expect(isPickedElementPayload("string")).toBe(false); | ||
| 71 | expect(isPickedElementPayload(42)).toBe(false); | ||
| 72 | expect(isPickedElementPayload([])).toBe(false); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it.each<[string, Record<string, unknown>]>([ | ||
| 76 | ["missing pageUrl", validPayload({ pageUrl: undefined })], | ||
| 77 | ["wrong-type pageUrl", validPayload({ pageUrl: 123 })], | ||
| 78 | ["missing tagName", validPayload({ tagName: undefined })], | ||
| 79 | ["missing htmlPreview", validPayload({ htmlPreview: undefined })], | ||
| 80 | ["missing styles", validPayload({ styles: undefined })], | ||
| 81 | ["missing pickedAt", validPayload({ pickedAt: undefined })], | ||
| 82 | ["wrong-type pageTitle", validPayload({ pageTitle: 99 })], | ||
| 83 | ["wrong-type selector", validPayload({ selector: 99 })], | ||
| 84 | ["wrong-type componentName", validPayload({ componentName: 99 })], | ||
| 85 | ])("rejects payloads with %s", (_label, value) => { | ||
| 86 | expect(isPickedElementPayload(value)).toBe(false); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("rejects malformed source frames", () => { | ||
| 90 | expect( | ||
| 91 | isPickedElementPayload( | ||
| 92 | validPayload({ | ||
| 93 | source: { | ||
| 94 | functionName: 0, | ||
| 95 | fileName: null, | ||
| 96 | lineNumber: null, | ||
| 97 | columnNumber: null, | ||
| 98 | }, | ||
| 99 | }), | ||
| 100 | ), | ||
| 101 | ).toBe(false); | ||
| 102 | }); | ||
| 103 | |||
| 104 | it("rejects non-finite numeric line/column numbers", () => { | ||
| 105 | expect( | ||
| 106 | isPickedElementPayload( | ||
| 107 | validPayload({ | ||
| 108 | source: { | ||
| 109 | functionName: null, | ||
| 110 | fileName: null, | ||
| 111 | lineNumber: Number.POSITIVE_INFINITY, | ||
| 112 | columnNumber: null, | ||
| 113 | }, | ||
| 114 | }), | ||
| 115 | ), | ||
| 116 | ).toBe(false); | ||
| 117 | expect( | ||
| 118 | isPickedElementPayload( | ||
| 119 | validPayload({ | ||
| 120 | source: { | ||
| 121 | functionName: null, | ||
| 122 | fileName: null, | ||
| 123 | lineNumber: Number.NaN, | ||
| 124 | columnNumber: null, | ||
| 125 | }, | ||
| 126 | }), | ||
| 127 | ), | ||
| 128 | ).toBe(false); | ||
| 129 | }); | ||
| 130 | |||
| 131 | it("rejects malformed stack arrays", () => { | ||
| 132 | expect(isPickedElementPayload(validPayload({ stack: "not-an-array" }))).toBe(false); | ||
| 133 | expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false); | ||
| 134 | }); | ||
| 135 | }); | ||
| 136 | |||
| 137 | function validAnnotation(overrides?: Record<string, unknown>): Record<string, unknown> { | ||
| 138 | return { | ||
| 139 | id: "annotation_1", | ||
| 140 | pageUrl: "https://example.com/", | ||
| 141 | pageTitle: "Example", | ||
| 142 | comment: "Make this clearer", | ||
| 143 | elements: [ | ||
| 144 | { | ||
| 145 | id: "element_1", | ||
| 146 | element: validPayload(), | ||
| 147 | rect: { x: 10, y: 20, width: 100, height: 40 }, | ||
| 148 | }, | ||
| 149 | ], | ||
| 150 | regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], | ||
| 151 | strokes: [ | ||
| 152 | { | ||
| 153 | id: "stroke_1", | ||
| 154 | color: "#7c3aed", | ||
| 155 | width: 4, | ||
| 156 | points: [ | ||
| 157 | { x: 10, y: 10 }, | ||
| 158 | { x: 20, y: 20 }, | ||
| 159 | ], | ||
| 160 | bounds: { x: 6, y: 6, width: 18, height: 18 }, | ||
| 161 | }, | ||
| 162 | ], | ||
| 163 | styleChanges: [ | ||
| 164 | { | ||
| 165 | targetId: "element_1", | ||
| 166 | selector: "button.submit", | ||
| 167 | property: "opacity", | ||
| 168 | previousValue: "1", | ||
| 169 | value: "0.5", | ||
| 170 | }, | ||
| 171 | ], | ||
| 172 | screenshot: null, | ||
| 173 | createdAt: "2026-06-11T00:00:00.000Z", | ||
| 174 | ...overrides, | ||
| 175 | }; | ||
| 176 | } | ||
| 177 | |||
| 178 | describe("isPreviewAnnotationPayload", () => { | ||
| 179 | it("accepts a structured annotation draft before screenshot capture", () => { | ||
| 180 | expect(isPreviewAnnotationPayload(validAnnotation())).toBe(true); | ||
| 181 | }); | ||
| 182 | |||
| 183 | it("rejects screenshots supplied by the guest preload", () => { | ||
| 184 | expect(isPreviewAnnotationPayload(validAnnotation({ screenshot: { dataUrl: "bad" } }))).toBe( | ||
| 185 | false, | ||
| 186 | ); | ||
| 187 | }); | ||
| 188 | |||
| 189 | it("rejects malformed geometry and nested element payloads", () => { | ||
| 190 | expect( | ||
| 191 | isPreviewAnnotationPayload( | ||
| 192 | validAnnotation({ regions: [{ id: "region_1", rect: { x: 0, y: 0, width: "wide" } }] }), | ||
| 193 | ), | ||
| 194 | ).toBe(false); | ||
| 195 | expect( | ||
| 196 | isPreviewAnnotationPayload( | ||
| 197 | validAnnotation({ elements: [{ id: "element_1", element: {}, rect: {} }] }), | ||
| 198 | ), | ||
| 199 | ).toBe(false); | ||
| 200 | }); | ||
| 201 | }); | ||
apps/desktop/src/picked-element-payload.ts deleted-146| ... | @@ -1,146 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * Strict structural validator for `PickedElementPayload` messages received | ||
| 3 | * from the in-page picker preload (`apps/desktop/src/preview-pick-preload.ts`) | ||
| 4 | * via `wc.ipc`. Lives in its own electron-free module so the validator is | ||
| 5 | * trivially unit-testable. | ||
| 6 | * | ||
| 7 | * Validation must be tight: downstream `normalizeElementContextSelection` | ||
| 8 | * calls `.trim()` on incoming strings, so a malformed payload (preload bug, | ||
| 9 | * future schema mismatch, malicious page that intercepts the preload's IPC | ||
| 10 | * channel via prototype pollution) would otherwise throw deep in the | ||
| 11 | * renderer and the chip silently never appears. | ||
| 12 | */ | ||
| 13 | import type { PickedElementPayload, PreviewAnnotationPayload } from "@t3tools/contracts"; | ||
| 14 | |||
| 15 | function isStringOrNull(value: unknown): value is string | null { | ||
| 16 | return value === null || typeof value === "string"; | ||
| 17 | } | ||
| 18 | |||
| 19 | function isFiniteNumberOrNull(value: unknown): value is number | null { | ||
| 20 | return value === null || (typeof value === "number" && Number.isFinite(value)); | ||
| 21 | } | ||
| 22 | |||
| 23 | function isPickedStackFrame(value: unknown): boolean { | ||
| 24 | if (typeof value !== "object" || value === null) return false; | ||
| 25 | const frame = value as Record<string, unknown>; | ||
| 26 | return ( | ||
| 27 | isStringOrNull(frame["functionName"]) && | ||
| 28 | isStringOrNull(frame["fileName"]) && | ||
| 29 | isFiniteNumberOrNull(frame["lineNumber"]) && | ||
| 30 | isFiniteNumberOrNull(frame["columnNumber"]) | ||
| 31 | ); | ||
| 32 | } | ||
| 33 | |||
| 34 | export function isPickedElementPayload(value: unknown): value is PickedElementPayload { | ||
| 35 | if (typeof value !== "object" || value === null) return false; | ||
| 36 | const c = value as Record<string, unknown>; | ||
| 37 | if (typeof c["pageUrl"] !== "string") return false; | ||
| 38 | if (typeof c["tagName"] !== "string") return false; | ||
| 39 | if (typeof c["htmlPreview"] !== "string") return false; | ||
| 40 | if (typeof c["styles"] !== "string") return false; | ||
| 41 | if (typeof c["pickedAt"] !== "string") return false; | ||
| 42 | if (!isStringOrNull(c["pageTitle"])) return false; | ||
| 43 | if (!isStringOrNull(c["selector"])) return false; | ||
| 44 | if (!isStringOrNull(c["componentName"])) return false; | ||
| 45 | if (c["source"] !== null && !isPickedStackFrame(c["source"])) return false; | ||
| 46 | if (!Array.isArray(c["stack"])) return false; | ||
| 47 | if (!c["stack"].every(isPickedStackFrame)) return false; | ||
| 48 | return true; | ||
| 49 | } | ||
| 50 | |||
| 51 | function isRect(value: unknown): boolean { | ||
| 52 | if (typeof value !== "object" || value === null) return false; | ||
| 53 | const rect = value as Record<string, unknown>; | ||
| 54 | return ["x", "y", "width", "height"].every( | ||
| 55 | (key) => typeof rect[key] === "number" && Number.isFinite(rect[key]), | ||
| 56 | ); | ||
| 57 | } | ||
| 58 | |||
| 59 | function isPoint(value: unknown): boolean { | ||
| 60 | if (typeof value !== "object" || value === null) return false; | ||
| 61 | const point = value as Record<string, unknown>; | ||
| 62 | return ( | ||
| 63 | typeof point["x"] === "number" && | ||
| 64 | Number.isFinite(point["x"]) && | ||
| 65 | typeof point["y"] === "number" && | ||
| 66 | Number.isFinite(point["y"]) | ||
| 67 | ); | ||
| 68 | } | ||
| 69 | |||
| 70 | export function isPreviewAnnotationPayload(value: unknown): value is PreviewAnnotationPayload { | ||
| 71 | if (typeof value !== "object" || value === null) return false; | ||
| 72 | const annotation = value as Record<string, unknown>; | ||
| 73 | if (typeof annotation["id"] !== "string") return false; | ||
| 74 | if (typeof annotation["pageUrl"] !== "string") return false; | ||
| 75 | if (!isStringOrNull(annotation["pageTitle"])) return false; | ||
| 76 | if (typeof annotation["comment"] !== "string") return false; | ||
| 77 | if (typeof annotation["createdAt"] !== "string") return false; | ||
| 78 | if (annotation["screenshot"] !== null) return false; | ||
| 79 | |||
| 80 | const elements = annotation["elements"]; | ||
| 81 | if (!Array.isArray(elements)) return false; | ||
| 82 | if ( | ||
| 83 | !elements.every((entry) => { | ||
| 84 | if (typeof entry !== "object" || entry === null) return false; | ||
| 85 | const target = entry as Record<string, unknown>; | ||
| 86 | return ( | ||
| 87 | typeof target["id"] === "string" && | ||
| 88 | isPickedElementPayload(target["element"]) && | ||
| 89 | isRect(target["rect"]) | ||
| 90 | ); | ||
| 91 | }) | ||
| 92 | ) { | ||
| 93 | return false; | ||
| 94 | } | ||
| 95 | |||
| 96 | const regions = annotation["regions"]; | ||
| 97 | if (!Array.isArray(regions)) return false; | ||
| 98 | if ( | ||
| 99 | !regions.every((entry) => { | ||
| 100 | if (typeof entry !== "object" || entry === null) return false; | ||
| 101 | const target = entry as Record<string, unknown>; | ||
| 102 | return typeof target["id"] === "string" && isRect(target["rect"]); | ||
| 103 | }) | ||
| 104 | ) { | ||
| 105 | return false; | ||
| 106 | } | ||
| 107 | |||
| 108 | const strokes = annotation["strokes"]; | ||
| 109 | if (!Array.isArray(strokes)) return false; | ||
| 110 | if ( | ||
| 111 | !strokes.every((entry) => { | ||
| 112 | if (typeof entry !== "object" || entry === null) return false; | ||
| 113 | const target = entry as Record<string, unknown>; | ||
| 114 | return ( | ||
| 115 | typeof target["id"] === "string" && | ||
| 116 | typeof target["color"] === "string" && | ||
| 117 | typeof target["width"] === "number" && | ||
| 118 | Number.isFinite(target["width"]) && | ||
| 119 | Array.isArray(target["points"]) && | ||
| 120 | target["points"].every(isPoint) && | ||
| 121 | isRect(target["bounds"]) | ||
| 122 | ); | ||
| 123 | }) | ||
| 124 | ) { | ||
| 125 | return false; | ||
| 126 | } | ||
| 127 | |||
| 128 | const styleChanges = annotation["styleChanges"]; | ||
| 129 | if (!Array.isArray(styleChanges)) return false; | ||
| 130 | if ( | ||
| 131 | !styleChanges.every((entry) => { | ||
| 132 | if (typeof entry !== "object" || entry === null) return false; | ||
| 133 | const change = entry as Record<string, unknown>; | ||
| 134 | return ( | ||
| 135 | typeof change["targetId"] === "string" && | ||
| 136 | isStringOrNull(change["selector"]) && | ||
| 137 | typeof change["property"] === "string" && | ||
| 138 | typeof change["previousValue"] === "string" && | ||
| 139 | typeof change["value"] === "string" | ||
| 140 | ); | ||
| 141 | }) | ||
| 142 | ) { | ||
| 143 | return false; | ||
| 144 | } | ||
| 145 | return true; | ||
| 146 | } | ||
apps/desktop/src/playwright-injected-runtime.test.ts deleted-20| ... | @@ -1,20 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | playwrightInjectedRuntimeInstallExpression, | ||
| 5 | playwrightInjectedRuntimeSource, | ||
| 6 | } from "./playwright-injected-runtime.ts"; | ||
| 7 | |||
| 8 | describe("playwright injected runtime", () => { | ||
| 9 | it("extracts the pinned runtime from playwright-core", async () => { | ||
| 10 | const source = await playwrightInjectedRuntimeSource(); | ||
| 11 | expect(source.length).toBeGreaterThan(100_000); | ||
| 12 | expect(source).toContain("InjectedScript"); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("builds an idempotent install expression", async () => { | ||
| 16 | const expression = await playwrightInjectedRuntimeInstallExpression(); | ||
| 17 | expect(expression).toContain("__t3PlaywrightInjected"); | ||
| 18 | expect(expression).toContain('testIdAttributeName":"data-testid'); | ||
| 19 | }); | ||
| 20 | }); | ||
apps/desktop/src/playwright-injected-runtime.ts deleted-49| ... | @@ -1,49 +0,0 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import { createRequire } from "node:module"; | ||
| 3 | import { dirname, join } from "node:path"; | ||
| 4 | import { readFile } from "node:fs/promises"; | ||
| 5 | import { runInNewContext } from "node:vm"; | ||
| 6 | |||
| 7 | const require = createRequire(import.meta.url); | ||
| 8 | let sourcePromise: Promise<string> | null = null; | ||
| 9 | |||
| 10 | export const playwrightInjectedRuntimeSource = (): Promise<string> => { | ||
| 11 | sourcePromise ??= (async () => { | ||
| 12 | const packageJsonPath = require.resolve("playwright-core/package.json"); | ||
| 13 | const coreBundle = await readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8"); | ||
| 14 | const marker = "source3 = "; | ||
| 15 | const start = coreBundle.indexOf(marker); | ||
| 16 | if (start < 0) throw new Error("Playwright injected runtime marker was not found."); | ||
| 17 | const literalStart = start + marker.length; | ||
| 18 | const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart); | ||
| 19 | if (literalEnd < 0) throw new Error("Playwright injected runtime terminator was not found."); | ||
| 20 | const literal = coreBundle.slice(literalStart, literalEnd); | ||
| 21 | const source = runInNewContext(literal, Object.create(null), { timeout: 1_000 }); | ||
| 22 | if (typeof source !== "string" || source.length < 100_000) { | ||
| 23 | throw new Error("Playwright injected runtime extraction returned invalid source."); | ||
| 24 | } | ||
| 25 | return source; | ||
| 26 | })(); | ||
| 27 | return sourcePromise; | ||
| 28 | }; | ||
| 29 | |||
| 30 | export const playwrightInjectedRuntimeInstallExpression = async (): Promise<string> => { | ||
| 31 | const source = await playwrightInjectedRuntimeSource(); | ||
| 32 | const options = { | ||
| 33 | isUnderTest: false, | ||
| 34 | sdkLanguage: "javascript", | ||
| 35 | testIdAttributeName: "data-testid", | ||
| 36 | stableRafCount: 1, | ||
| 37 | browserName: "chromium", | ||
| 38 | shouldPrependErrorPrefix: false, | ||
| 39 | isUtilityWorld: false, | ||
| 40 | customEngines: [], | ||
| 41 | }; | ||
| 42 | return `(() => { | ||
| 43 | if (globalThis.__t3PlaywrightInjected) return true; | ||
| 44 | const module = { exports: {} }; | ||
| 45 | ${source} | ||
| 46 | globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${JSON.stringify(options)}); | ||
| 47 | return true; | ||
| 48 | })()`; | ||
| 49 | }; | ||
apps/desktop/src/preview-annotation-styles.generated.ts deleted-3| ... | @@ -1,3 +0,0 @@ | ||
| 1 | // Generated by scripts/build-preview-annotation-css.mjs. Do not edit. | ||
| 2 | export const previewAnnotationStyles = | ||
| 3 | '/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: calc(var(--spacing) * 0);\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: calc(var(--spacing) * 0);\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: calc(var(--spacing) * 1);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: calc(var(--spacing) * 0);\n}\n.p-1 {\n padding: calc(var(--spacing) * 1);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: calc(var(--spacing) * 0);\n}\n.px-1 {\n padding-inline: calc(var(--spacing) * 1);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: calc(var(--spacing) * 1);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "<length>";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; | ||
apps/desktop/src/preview-annotation.css deleted-68| ... | @@ -1,68 +0,0 @@ | ||
| 1 | @import "tailwindcss"; | ||
| 2 | |||
| 3 | @theme inline { | ||
| 4 | --font-sans: var(--t3-font-sans); | ||
| 5 | --font-mono: var(--t3-font-mono); | ||
| 6 | --color-background: var(--t3-background); | ||
| 7 | --color-foreground: var(--t3-foreground); | ||
| 8 | --color-popover: var(--t3-popover); | ||
| 9 | --color-popover-foreground: var(--t3-popover-foreground); | ||
| 10 | --color-primary: var(--t3-primary); | ||
| 11 | --color-primary-foreground: var(--t3-primary-foreground); | ||
| 12 | --color-muted: var(--t3-muted); | ||
| 13 | --color-muted-foreground: var(--t3-muted-foreground); | ||
| 14 | --color-accent: var(--t3-accent); | ||
| 15 | --color-accent-foreground: var(--t3-accent-foreground); | ||
| 16 | --color-border: var(--t3-border); | ||
| 17 | --color-input: var(--t3-input); | ||
| 18 | --color-ring: var(--t3-ring); | ||
| 19 | --radius-sm: calc(var(--t3-radius) - 4px); | ||
| 20 | --radius-md: calc(var(--t3-radius) - 2px); | ||
| 21 | --radius-lg: var(--t3-radius); | ||
| 22 | --radius-xl: calc(var(--t3-radius) + 4px); | ||
| 23 | --radius-2xl: calc(var(--t3-radius) + 8px); | ||
| 24 | } | ||
| 25 | |||
| 26 | :host { | ||
| 27 | --t3-font-sans: | ||
| 28 | "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, | ||
| 29 | sans-serif; | ||
| 30 | --t3-font-mono: | ||
| 31 | "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; | ||
| 32 | --t3-radius: 0.625rem; | ||
| 33 | --t3-background: white; | ||
| 34 | --t3-foreground: oklch(0.269 0 0); | ||
| 35 | --t3-popover: white; | ||
| 36 | --t3-popover-foreground: oklch(0.269 0 0); | ||
| 37 | --t3-primary: oklch(0.488 0.217 264); | ||
| 38 | --t3-primary-foreground: white; | ||
| 39 | --t3-muted: rgb(0 0 0 / 4%); | ||
| 40 | --t3-muted-foreground: oklch(0.556 0 0); | ||
| 41 | --t3-accent: rgb(0 0 0 / 4%); | ||
| 42 | --t3-accent-foreground: oklch(0.269 0 0); | ||
| 43 | --t3-border: rgb(0 0 0 / 8%); | ||
| 44 | --t3-input: rgb(0 0 0 / 10%); | ||
| 45 | --t3-ring: oklch(0.488 0.217 264); | ||
| 46 | color: var(--t3-foreground); | ||
| 47 | font-family: var(--t3-font-sans); | ||
| 48 | } | ||
| 49 | |||
| 50 | * { | ||
| 51 | box-sizing: border-box; | ||
| 52 | border-color: var(--t3-border); | ||
| 53 | } | ||
| 54 | |||
| 55 | button, | ||
| 56 | input, | ||
| 57 | select, | ||
| 58 | textarea { | ||
| 59 | font: inherit; | ||
| 60 | } | ||
| 61 | |||
| 62 | button:focus-visible, | ||
| 63 | input:focus-visible, | ||
| 64 | select:focus-visible, | ||
| 65 | textarea:focus-visible { | ||
| 66 | outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent); | ||
| 67 | outline-offset: 1px; | ||
| 68 | } | ||
apps/desktop/src/preview-pick-label-position.ts deleted-46| ... | @@ -1,46 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * Pure clamp/flip math for the floating label that follows the cursor while | ||
| 3 | * the user is picking an element in the in-app browser. Lives in its own | ||
| 4 | * electron-free module so the geometry can be unit-tested without spinning | ||
| 5 | * up an Electron preload context (`preview-pick-preload.ts` itself imports | ||
| 6 | * `electron` and `react-grab/primitives`, which can't load under vitest). | ||
| 7 | * | ||
| 8 | * - Horizontally pins the label to `targetLeft`, clamped into | ||
| 9 | * `[VIEWPORT_MARGIN, viewportWidth - labelWidth - VIEWPORT_MARGIN]`. | ||
| 10 | * - Vertically prefers above the target. If the label would overflow the | ||
| 11 | * top, flips below; if THAT also overflows the bottom, pins to the | ||
| 12 | * bottom margin (better to overlap the highlight than disappear). | ||
| 13 | */ | ||
| 14 | |||
| 15 | /** Distance in CSS pixels between the highlight and the floating label. */ | ||
| 16 | export const LABEL_GAP = 4; | ||
| 17 | /** Minimum padding the label keeps from any viewport edge. */ | ||
| 18 | export const VIEWPORT_MARGIN = 4; | ||
| 19 | |||
| 20 | export function computeLabelPosition(input: { | ||
| 21 | targetLeft: number; | ||
| 22 | targetTop: number; | ||
| 23 | targetBottom: number; | ||
| 24 | labelWidth: number; | ||
| 25 | labelHeight: number; | ||
| 26 | viewportWidth: number; | ||
| 27 | viewportHeight: number; | ||
| 28 | }): { x: number; y: number } { | ||
| 29 | const { targetLeft, targetTop, targetBottom, labelWidth, labelHeight } = input; | ||
| 30 | const { viewportWidth, viewportHeight } = input; | ||
| 31 | |||
| 32 | let x = targetLeft; | ||
| 33 | const maxX = viewportWidth - labelWidth - VIEWPORT_MARGIN; | ||
| 34 | if (x > maxX) x = maxX; | ||
| 35 | if (x < VIEWPORT_MARGIN) x = VIEWPORT_MARGIN; | ||
| 36 | |||
| 37 | let y = targetTop - labelHeight - LABEL_GAP; | ||
| 38 | if (y < VIEWPORT_MARGIN) { | ||
| 39 | y = targetBottom + LABEL_GAP; | ||
| 40 | if (y + labelHeight > viewportHeight - VIEWPORT_MARGIN) { | ||
| 41 | y = Math.max(VIEWPORT_MARGIN, viewportHeight - labelHeight - VIEWPORT_MARGIN); | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | return { x, y }; | ||
| 46 | } | ||
apps/desktop/src/preview-pick-preload.test.ts deleted-86| ... | @@ -1,86 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { computeLabelPosition } from "./preview-pick-label-position.ts"; | ||
| 4 | |||
| 5 | const VIEWPORT = { viewportWidth: 1280, viewportHeight: 800 }; | ||
| 6 | |||
| 7 | describe("computeLabelPosition", () => { | ||
| 8 | it("anchors to the element's top-left when there's room above and to the right", () => { | ||
| 9 | const { x, y } = computeLabelPosition({ | ||
| 10 | ...VIEWPORT, | ||
| 11 | targetLeft: 200, | ||
| 12 | targetTop: 200, | ||
| 13 | targetBottom: 240, | ||
| 14 | labelWidth: 120, | ||
| 15 | labelHeight: 18, | ||
| 16 | }); | ||
| 17 | expect(x).toBe(200); | ||
| 18 | // 200 (top) - 18 (height) - 4 (gap) | ||
| 19 | expect(y).toBe(200 - 18 - 4); | ||
| 20 | }); | ||
| 21 | |||
| 22 | it("clamps left edge so the label stays inside the viewport", () => { | ||
| 23 | const { x } = computeLabelPosition({ | ||
| 24 | ...VIEWPORT, | ||
| 25 | targetLeft: -50, | ||
| 26 | targetTop: 200, | ||
| 27 | targetBottom: 240, | ||
| 28 | labelWidth: 120, | ||
| 29 | labelHeight: 18, | ||
| 30 | }); | ||
| 31 | expect(x).toBe(4); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("clamps right edge when the label would overflow the viewport (the bug we shipped)", () => { | ||
| 35 | const { x } = computeLabelPosition({ | ||
| 36 | ...VIEWPORT, | ||
| 37 | targetLeft: 1240, | ||
| 38 | targetTop: 200, | ||
| 39 | targetBottom: 240, | ||
| 40 | labelWidth: 200, | ||
| 41 | labelHeight: 18, | ||
| 42 | }); | ||
| 43 | // viewportWidth (1280) - labelWidth (200) - margin (4) = 1076 | ||
| 44 | expect(x).toBe(1076); | ||
| 45 | }); | ||
| 46 | |||
| 47 | it("flips the label below the element when there's no room above", () => { | ||
| 48 | const { y } = computeLabelPosition({ | ||
| 49 | ...VIEWPORT, | ||
| 50 | targetLeft: 200, | ||
| 51 | targetTop: 4, | ||
| 52 | targetBottom: 44, | ||
| 53 | labelWidth: 120, | ||
| 54 | labelHeight: 18, | ||
| 55 | }); | ||
| 56 | // labelY = 4 - 18 - 4 = -18 → flip → 44 + 4 = 48 | ||
| 57 | expect(y).toBe(48); | ||
| 58 | }); | ||
| 59 | |||
| 60 | it("pins to the bottom margin when the element fills the viewport (no room above OR below)", () => { | ||
| 61 | const { y } = computeLabelPosition({ | ||
| 62 | ...VIEWPORT, | ||
| 63 | targetLeft: 200, | ||
| 64 | targetTop: 0, | ||
| 65 | targetBottom: 800, | ||
| 66 | labelWidth: 120, | ||
| 67 | labelHeight: 18, | ||
| 68 | }); | ||
| 69 | // Above overflows top → flip below = 800 + 4 = 804 → also overflows | ||
| 70 | // bottom → pin to viewportHeight - labelHeight - margin = 778. | ||
| 71 | expect(y).toBe(800 - 18 - 4); | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("never returns a negative coordinate", () => { | ||
| 75 | const { x, y } = computeLabelPosition({ | ||
| 76 | ...VIEWPORT, | ||
| 77 | targetLeft: -1000, | ||
| 78 | targetTop: -1000, | ||
| 79 | targetBottom: -900, | ||
| 80 | labelWidth: 5000, | ||
| 81 | labelHeight: 5000, | ||
| 82 | }); | ||
| 83 | expect(x).toBeGreaterThanOrEqual(0); | ||
| 84 | expect(y).toBeGreaterThanOrEqual(0); | ||
| 85 | }); | ||
| 86 | }); | ||
apps/desktop/src/preview-pick-preload.ts+1-1262| ... | @@ -1,1262 +1 @@ | ... | @@ -1,1262 +1 @@ |
| 1 | // @effect-diagnostics globalDate:off | 1 | import "./preview/PickPreload.ts"; |
| 2 | import { ipcRenderer } from "electron"; | ||
| 3 | import { getElementContext } from "react-grab/primitives"; | ||
| 4 | import type { | ||
| 5 | DesktopPreviewAnnotationTheme, | ||
| 6 | PickedElementPayload, | ||
| 7 | PickedElementStackFrame, | ||
| 8 | PreviewAnnotationPayload, | ||
| 9 | PreviewAnnotationPoint, | ||
| 10 | PreviewAnnotationRect, | ||
| 11 | PreviewAnnotationRegionTarget, | ||
| 12 | PreviewAnnotationStrokeTarget, | ||
| 13 | PreviewAnnotationStyleChange, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | |||
| 16 | import { previewAnnotationStyles } from "./preview-annotation-styles.generated.ts"; | ||
| 17 | |||
| 18 | const START_PICK_CHANNEL = "preview:start-pick"; | ||
| 19 | const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; | ||
| 20 | const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; | ||
| 21 | const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; | ||
| 22 | const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; | ||
| 23 | const HUMAN_INPUT_CHANNEL = "preview:human-input"; | ||
| 24 | const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; | ||
| 25 | const Z_INDEX_OVERLAY = 2147483646; | ||
| 26 | const PRIMARY = "var(--t3-primary)"; | ||
| 27 | const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; | ||
| 28 | const MAX_MARQUEE_ELEMENTS = 20; | ||
| 29 | const CONTENT_LAYER_Z_INDEX = 1; | ||
| 30 | const CHROME_LAYER_Z_INDEX = 10; | ||
| 31 | |||
| 32 | type AnnotationTool = "select" | "marquee" | "draw" | "erase"; | ||
| 33 | |||
| 34 | interface SelectedElement { | ||
| 35 | id: string; | ||
| 36 | element: Element; | ||
| 37 | outline: HTMLDivElement; | ||
| 38 | label: HTMLDivElement; | ||
| 39 | baselineStyles: Map<string, string>; | ||
| 40 | } | ||
| 41 | |||
| 42 | interface AnnotationSession { | ||
| 43 | teardown: (notifyMain: boolean) => void; | ||
| 44 | applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; | ||
| 45 | } | ||
| 46 | |||
| 47 | let activeSession: AnnotationSession | null = null; | ||
| 48 | let idSequence = 0; | ||
| 49 | let annotationTheme: DesktopPreviewAnnotationTheme | null = null; | ||
| 50 | |||
| 51 | const applyAnnotationTheme = ( | ||
| 52 | host: HTMLElement, | ||
| 53 | theme: DesktopPreviewAnnotationTheme | null, | ||
| 54 | ): void => { | ||
| 55 | if (!theme) return; | ||
| 56 | host.style.colorScheme = theme.colorScheme; | ||
| 57 | const variables = { | ||
| 58 | "--t3-radius": theme.radius, | ||
| 59 | "--t3-background": theme.background, | ||
| 60 | "--t3-foreground": theme.foreground, | ||
| 61 | "--t3-popover": theme.popover, | ||
| 62 | "--t3-popover-foreground": theme.popoverForeground, | ||
| 63 | "--t3-primary": theme.primary, | ||
| 64 | "--t3-primary-foreground": theme.primaryForeground, | ||
| 65 | "--t3-muted": theme.muted, | ||
| 66 | "--t3-muted-foreground": theme.mutedForeground, | ||
| 67 | "--t3-accent": theme.accent, | ||
| 68 | "--t3-accent-foreground": theme.accentForeground, | ||
| 69 | "--t3-border": theme.border, | ||
| 70 | "--t3-input": theme.input, | ||
| 71 | "--t3-ring": theme.ring, | ||
| 72 | "--t3-font-sans": theme.fontSans, | ||
| 73 | "--t3-font-mono": theme.fontMono, | ||
| 74 | }; | ||
| 75 | for (const [name, value] of Object.entries(variables)) { | ||
| 76 | host.style.setProperty(name, value); | ||
| 77 | } | ||
| 78 | }; | ||
| 79 | |||
| 80 | const reportHumanPointerInput = (event: PointerEvent): void => { | ||
| 81 | if (!event.isTrusted) return; | ||
| 82 | ipcRenderer.send(HUMAN_INPUT_CHANNEL, { | ||
| 83 | kind: "pointer", | ||
| 84 | x: event.clientX, | ||
| 85 | y: event.clientY, | ||
| 86 | button: event.button, | ||
| 87 | }); | ||
| 88 | }; | ||
| 89 | |||
| 90 | const reportHumanKeyInput = (event: KeyboardEvent): void => { | ||
| 91 | if (!event.isTrusted) return; | ||
| 92 | ipcRenderer.send(HUMAN_INPUT_CHANNEL, { | ||
| 93 | kind: "key", | ||
| 94 | key: event.key, | ||
| 95 | code: event.code, | ||
| 96 | }); | ||
| 97 | }; | ||
| 98 | |||
| 99 | window.addEventListener("pointerdown", reportHumanPointerInput, true); | ||
| 100 | window.addEventListener("keydown", reportHumanKeyInput, true); | ||
| 101 | |||
| 102 | const nextId = (prefix: string): string => { | ||
| 103 | idSequence += 1; | ||
| 104 | return `${prefix}_${idSequence.toString(36)}`; | ||
| 105 | }; | ||
| 106 | |||
| 107 | const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({ | ||
| 108 | x: rect.left, | ||
| 109 | y: rect.top, | ||
| 110 | width: rect.width, | ||
| 111 | height: rect.height, | ||
| 112 | }); | ||
| 113 | |||
| 114 | const normalizeRect = ( | ||
| 115 | startX: number, | ||
| 116 | startY: number, | ||
| 117 | endX: number, | ||
| 118 | endY: number, | ||
| 119 | ): PreviewAnnotationRect => ({ | ||
| 120 | x: Math.min(startX, endX), | ||
| 121 | y: Math.min(startY, endY), | ||
| 122 | width: Math.abs(endX - startX), | ||
| 123 | height: Math.abs(endY - startY), | ||
| 124 | }); | ||
| 125 | |||
| 126 | const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; | ||
| 127 | |||
| 128 | function unionRects( | ||
| 129 | rects: ReadonlyArray<PreviewAnnotationRect>, | ||
| 130 | padding = 20, | ||
| 131 | ): PreviewAnnotationRect | null { | ||
| 132 | if (rects.length === 0) return null; | ||
| 133 | const left = Math.min(...rects.map((rect) => rect.x)); | ||
| 134 | const top = Math.min(...rects.map((rect) => rect.y)); | ||
| 135 | const right = Math.max(...rects.map((rect) => rect.x + rect.width)); | ||
| 136 | const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); | ||
| 137 | const x = Math.max(0, left - padding); | ||
| 138 | const y = Math.max(0, top - padding); | ||
| 139 | const maxWidth = Math.max(1, window.innerWidth - x); | ||
| 140 | const maxHeight = Math.max(1, window.innerHeight - y); | ||
| 141 | return { | ||
| 142 | x, | ||
| 143 | y, | ||
| 144 | width: Math.min(maxWidth, right - left + padding * 2), | ||
| 145 | height: Math.min(maxHeight, bottom - top + padding * 2), | ||
| 146 | }; | ||
| 147 | } | ||
| 148 | |||
| 149 | function isAnnotationNode(element: Element): boolean { | ||
| 150 | return element instanceof Element && element.closest(`[${OVERLAY_ATTRIBUTE}]`) !== null; | ||
| 151 | } | ||
| 152 | |||
| 153 | function pickFromPoint(clientX: number, clientY: number): Element | null { | ||
| 154 | for (const candidate of document.elementsFromPoint(clientX, clientY)) { | ||
| 155 | if (!(candidate instanceof Element)) continue; | ||
| 156 | if (isAnnotationNode(candidate)) continue; | ||
| 157 | if (candidate === document.documentElement || candidate === document.body) continue; | ||
| 158 | return candidate; | ||
| 159 | } | ||
| 160 | return null; | ||
| 161 | } | ||
| 162 | |||
| 163 | function describeRawElement(element: Element): string { | ||
| 164 | const tag = element.tagName.toLowerCase(); | ||
| 165 | const id = element.id ? `#${element.id}` : ""; | ||
| 166 | const classes = | ||
| 167 | element instanceof HTMLElement && typeof element.className === "string" | ||
| 168 | ? element.className | ||
| 169 | .trim() | ||
| 170 | .split(/\s+/) | ||
| 171 | .filter(Boolean) | ||
| 172 | .slice(0, 2) | ||
| 173 | .map((name) => `.${name}`) | ||
| 174 | .join("") | ||
| 175 | : ""; | ||
| 176 | return `${tag}${id}${classes}`; | ||
| 177 | } | ||
| 178 | |||
| 179 | function createBox(color: string, fill: string): HTMLDivElement { | ||
| 180 | const node = document.createElement("div"); | ||
| 181 | node.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 182 | node.style.cssText = [ | ||
| 183 | "position:fixed", | ||
| 184 | "pointer-events:none", | ||
| 185 | `border:2px solid ${color}`, | ||
| 186 | `background:${fill}`, | ||
| 187 | "border-radius:3px", | ||
| 188 | "box-sizing:border-box", | ||
| 189 | "display:none", | ||
| 190 | `z-index:${CONTENT_LAYER_Z_INDEX}`, | ||
| 191 | ].join(";"); | ||
| 192 | return node; | ||
| 193 | } | ||
| 194 | |||
| 195 | function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { | ||
| 196 | node.style.display = "block"; | ||
| 197 | node.style.transform = `translate(${rect.x}px, ${rect.y}px)`; | ||
| 198 | node.style.width = `${rect.width}px`; | ||
| 199 | node.style.height = `${rect.height}px`; | ||
| 200 | } | ||
| 201 | |||
| 202 | function createLabel(): HTMLDivElement { | ||
| 203 | const label = document.createElement("div"); | ||
| 204 | label.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 205 | label.className = | ||
| 206 | "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"; | ||
| 207 | label.style.cssText = [ | ||
| 208 | "position:fixed", | ||
| 209 | "pointer-events:none", | ||
| 210 | "white-space:nowrap", | ||
| 211 | "text-overflow:ellipsis", | ||
| 212 | `z-index:${CONTENT_LAYER_Z_INDEX}`, | ||
| 213 | ].join(";"); | ||
| 214 | return label; | ||
| 215 | } | ||
| 216 | |||
| 217 | function updateSelectedVisual(target: SelectedElement): void { | ||
| 218 | if (!target.element.isConnected) { | ||
| 219 | target.outline.style.display = "none"; | ||
| 220 | target.label.style.display = "none"; | ||
| 221 | return; | ||
| 222 | } | ||
| 223 | const rect = target.element.getBoundingClientRect(); | ||
| 224 | positionBox(target.outline, rectFromDomRect(rect)); | ||
| 225 | target.label.textContent = describeRawElement(target.element); | ||
| 226 | target.label.style.display = "block"; | ||
| 227 | target.label.style.transform = `translate(${Math.max(4, rect.left)}px, ${Math.max(4, rect.top - 22)}px)`; | ||
| 228 | } | ||
| 229 | |||
| 230 | function toStackFrame(frame: { | ||
| 231 | functionName?: string; | ||
| 232 | fileName?: string; | ||
| 233 | lineNumber?: number; | ||
| 234 | columnNumber?: number; | ||
| 235 | }): PickedElementStackFrame { | ||
| 236 | return { | ||
| 237 | functionName: frame.functionName ?? null, | ||
| 238 | fileName: frame.fileName ?? null, | ||
| 239 | lineNumber: frame.lineNumber ?? null, | ||
| 240 | columnNumber: frame.columnNumber ?? null, | ||
| 241 | }; | ||
| 242 | } | ||
| 243 | |||
| 244 | async function captureElement(element: Element): Promise<PickedElementPayload | null> { | ||
| 245 | try { | ||
| 246 | const context = await getElementContext(element); | ||
| 247 | const stack = (context.stack ?? []).map(toStackFrame); | ||
| 248 | return { | ||
| 249 | pageUrl: location.href, | ||
| 250 | pageTitle: document.title?.trim() || null, | ||
| 251 | tagName: element.tagName.toLowerCase(), | ||
| 252 | selector: context.selector, | ||
| 253 | htmlPreview: context.htmlPreview ?? "", | ||
| 254 | componentName: context.componentName, | ||
| 255 | source: stack[0] ?? null, | ||
| 256 | stack, | ||
| 257 | styles: context.styles ?? "", | ||
| 258 | pickedAt: new Date().toISOString(), | ||
| 259 | }; | ||
| 260 | } catch { | ||
| 261 | return null; | ||
| 262 | } | ||
| 263 | } | ||
| 264 | |||
| 265 | function createButton(label: string, title: string): HTMLButtonElement { | ||
| 266 | const button = document.createElement("button"); | ||
| 267 | button.type = "button"; | ||
| 268 | button.textContent = label; | ||
| 269 | button.title = title; | ||
| 270 | button.className = | ||
| 271 | "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"; | ||
| 272 | return button; | ||
| 273 | } | ||
| 274 | |||
| 275 | function styleControl(input: HTMLInputElement | HTMLSelectElement): void { | ||
| 276 | input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value"); | ||
| 277 | input.className = | ||
| 278 | "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"; | ||
| 279 | } | ||
| 280 | |||
| 281 | function createUnitControl(input: HTMLInputElement): HTMLElement { | ||
| 282 | const wrapper = document.createElement("div"); | ||
| 283 | wrapper.style.cssText = "position:relative;min-width:0"; | ||
| 284 | const unit = document.createElement("span"); | ||
| 285 | unit.textContent = input.dataset.unit ?? ""; | ||
| 286 | unit.className = | ||
| 287 | "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground"; | ||
| 288 | wrapper.append(input, unit); | ||
| 289 | return wrapper; | ||
| 290 | } | ||
| 291 | |||
| 292 | function createField( | ||
| 293 | labelText: string, | ||
| 294 | input: HTMLInputElement | HTMLSelectElement, | ||
| 295 | ): HTMLLabelElement { | ||
| 296 | const label = document.createElement("label"); | ||
| 297 | label.className = | ||
| 298 | "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; | ||
| 299 | const text = document.createElement("span"); | ||
| 300 | text.textContent = labelText; | ||
| 301 | styleControl(input); | ||
| 302 | label.append( | ||
| 303 | text, | ||
| 304 | input instanceof HTMLInputElement && input.dataset.unit ? createUnitControl(input) : input, | ||
| 305 | ); | ||
| 306 | return label; | ||
| 307 | } | ||
| 308 | |||
| 309 | function createStyleSection(): HTMLElement { | ||
| 310 | const section = document.createElement("section"); | ||
| 311 | section.className = "grid gap-1 border-t border-border py-2"; | ||
| 312 | return section; | ||
| 313 | } | ||
| 314 | |||
| 315 | function createUnitInput(unit: string, placeholder = "0"): HTMLInputElement { | ||
| 316 | const input = document.createElement("input"); | ||
| 317 | input.type = "number"; | ||
| 318 | input.placeholder = placeholder; | ||
| 319 | input.style.paddingRight = "30px"; | ||
| 320 | input.dataset.unit = unit; | ||
| 321 | return input; | ||
| 322 | } | ||
| 323 | |||
| 324 | function pathFromPoints(points: ReadonlyArray<PreviewAnnotationPoint>): string { | ||
| 325 | if (points.length === 0) return ""; | ||
| 326 | if (points.length === 1) return `M ${points[0]!.x} ${points[0]!.y} l 0.01 0.01`; | ||
| 327 | let path = `M ${points[0]!.x} ${points[0]!.y}`; | ||
| 328 | for (let index = 1; index < points.length - 1; index += 1) { | ||
| 329 | const current = points[index]!; | ||
| 330 | const next = points[index + 1]!; | ||
| 331 | path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`; | ||
| 332 | } | ||
| 333 | const last = points[points.length - 1]!; | ||
| 334 | path += ` L ${last.x} ${last.y}`; | ||
| 335 | return path; | ||
| 336 | } | ||
| 337 | |||
| 338 | function strokeBounds( | ||
| 339 | points: ReadonlyArray<PreviewAnnotationPoint>, | ||
| 340 | width: number, | ||
| 341 | ): PreviewAnnotationRect { | ||
| 342 | const xs = points.map((point) => point.x); | ||
| 343 | const ys = points.map((point) => point.y); | ||
| 344 | const padding = width + 3; | ||
| 345 | const left = Math.min(...xs) - padding; | ||
| 346 | const top = Math.min(...ys) - padding; | ||
| 347 | const right = Math.max(...xs) + padding; | ||
| 348 | const bottom = Math.max(...ys) + padding; | ||
| 349 | return { x: left, y: top, width: right - left, height: bottom - top }; | ||
| 350 | } | ||
| 351 | |||
| 352 | function startAnnotation(): void { | ||
| 353 | activeSession?.teardown(false); | ||
| 354 | let finished = false; | ||
| 355 | const host = document.createElement("div"); | ||
| 356 | host.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 357 | host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`; | ||
| 358 | applyAnnotationTheme(host, annotationTheme); | ||
| 359 | const shadowRoot = host.attachShadow({ mode: "closed" }); | ||
| 360 | const themeStyle = document.createElement("style"); | ||
| 361 | themeStyle.textContent = previewAnnotationStyles; | ||
| 362 | shadowRoot.appendChild(themeStyle); | ||
| 363 | |||
| 364 | const root = document.createElement("div"); | ||
| 365 | root.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 366 | root.className = "fixed inset-0 font-sans text-foreground"; | ||
| 367 | root.style.cssText = "pointer-events:none"; | ||
| 368 | const cursorStyle = document.createElement("style"); | ||
| 369 | cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 370 | 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; }`; | ||
| 371 | document.documentElement.appendChild(cursorStyle); | ||
| 372 | shadowRoot.appendChild(root); | ||
| 373 | |||
| 374 | const hoverOutline = createBox(PRIMARY, PRIMARY_FILL); | ||
| 375 | const marqueeBox = createBox(PRIMARY, PRIMARY_FILL); | ||
| 376 | root.append(hoverOutline, marqueeBox); | ||
| 377 | |||
| 378 | const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); | ||
| 379 | svg.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 380 | svg.setAttribute("width", "100%"); | ||
| 381 | svg.setAttribute("height", "100%"); | ||
| 382 | svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); | ||
| 383 | svg.style.cssText = "position:fixed;inset:0;overflow:visible;pointer-events:none"; | ||
| 384 | svg.style.zIndex = String(CONTENT_LAYER_Z_INDEX); | ||
| 385 | root.appendChild(svg); | ||
| 386 | |||
| 387 | const toolbar = document.createElement("div"); | ||
| 388 | toolbar.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 389 | toolbar.className = | ||
| 390 | "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"; | ||
| 391 | toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX); | ||
| 392 | root.appendChild(toolbar); | ||
| 393 | |||
| 394 | const editor = document.createElement("div"); | ||
| 395 | editor.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 396 | editor.className = | ||
| 397 | "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"; | ||
| 398 | editor.style.zIndex = String(CHROME_LAYER_Z_INDEX); | ||
| 399 | root.appendChild(editor); | ||
| 400 | |||
| 401 | const composerRow = document.createElement("div"); | ||
| 402 | composerRow.className = "flex items-start gap-2 p-2"; | ||
| 403 | |||
| 404 | const adjust = createButton("", "Expand annotation editor"); | ||
| 405 | adjust.setAttribute("aria-label", "Expand annotation editor"); | ||
| 406 | adjust.setAttribute("aria-expanded", "false"); | ||
| 407 | adjust.className += | ||
| 408 | " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"; | ||
| 409 | adjust.innerHTML = | ||
| 410 | '<svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true"><path d="M4 5h12M4 10h12M4 15h12M7 3v4M13 8v4M9 13v4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>'; | ||
| 411 | composerRow.appendChild(adjust); | ||
| 412 | |||
| 413 | const comment = document.createElement("textarea"); | ||
| 414 | comment.placeholder = "Describe the change…"; | ||
| 415 | comment.rows = 1; | ||
| 416 | comment.className = | ||
| 417 | "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"; | ||
| 418 | composerRow.appendChild(comment); | ||
| 419 | |||
| 420 | const dragHandle = document.createElement("button"); | ||
| 421 | dragHandle.type = "button"; | ||
| 422 | dragHandle.textContent = "â ¿"; | ||
| 423 | dragHandle.title = "Drag annotation editor"; | ||
| 424 | dragHandle.className = | ||
| 425 | "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"; | ||
| 426 | composerRow.appendChild(dragHandle); | ||
| 427 | |||
| 428 | const submit = createButton("Attach", "Attach annotation and screenshot"); | ||
| 429 | submit.className += | ||
| 430 | " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; | ||
| 431 | composerRow.appendChild(submit); | ||
| 432 | editor.appendChild(composerRow); | ||
| 433 | |||
| 434 | const stylePanel = document.createElement("div"); | ||
| 435 | stylePanel.className = | ||
| 436 | "hidden max-h-[min(176px,calc(100vh-180px))] overflow-auto border-t border-border bg-muted/40 px-3"; | ||
| 437 | editor.appendChild(stylePanel); | ||
| 438 | |||
| 439 | const selected = new Map<Element, SelectedElement>(); | ||
| 440 | const regions: PreviewAnnotationRegionTarget[] = []; | ||
| 441 | const strokes: PreviewAnnotationStrokeTarget[] = []; | ||
| 442 | const styleChanges = new Map<string, PreviewAnnotationStyleChange>(); | ||
| 443 | const toolButtons = new Map<AnnotationTool, HTMLButtonElement>(); | ||
| 444 | let tool: AnnotationTool = "select"; | ||
| 445 | let dragStart: PreviewAnnotationPoint | null = null; | ||
| 446 | let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; | ||
| 447 | let pendingCapture = false; | ||
| 448 | let editorExpanded = false; | ||
| 449 | let editorWasShown = false; | ||
| 450 | let editorPosition: { left: number; top: number } | null = null; | ||
| 451 | let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; | ||
| 452 | let editorLayoutFrame: number | null = null; | ||
| 453 | |||
| 454 | const resizeComment = (): void => { | ||
| 455 | const maxHeight = 96; | ||
| 456 | comment.style.height = "auto"; | ||
| 457 | const nextHeight = Math.min(comment.scrollHeight, maxHeight); | ||
| 458 | comment.style.height = `${nextHeight}px`; | ||
| 459 | comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden"; | ||
| 460 | queueEditorLayout(); | ||
| 461 | }; | ||
| 462 | comment.addEventListener("input", resizeComment); | ||
| 463 | |||
| 464 | const updateStatus = (): void => { | ||
| 465 | const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0; | ||
| 466 | editor.style.display = hasTargets ? "flex" : "none"; | ||
| 467 | submit.disabled = !hasTargets; | ||
| 468 | submit.style.opacity = hasTargets ? "1" : "0.45"; | ||
| 469 | adjust.disabled = !hasTargets; | ||
| 470 | stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none"; | ||
| 471 | queueEditorLayout(); | ||
| 472 | if (hasTargets && !editorWasShown) { | ||
| 473 | editorWasShown = true; | ||
| 474 | window.setTimeout(() => comment.focus({ preventScroll: true }), 0); | ||
| 475 | } | ||
| 476 | }; | ||
| 477 | |||
| 478 | const refreshToolButtons = (): void => { | ||
| 479 | for (const [candidate, button] of toolButtons) { | ||
| 480 | const active = candidate === tool; | ||
| 481 | button.classList.toggle("bg-primary/10", active); | ||
| 482 | button.classList.toggle("text-primary", active); | ||
| 483 | button.classList.toggle("text-foreground", !active); | ||
| 484 | } | ||
| 485 | if (tool !== "select") hoverOutline.style.display = "none"; | ||
| 486 | if (tool !== "marquee") marqueeBox.style.display = "none"; | ||
| 487 | document.documentElement.setAttribute("data-t3code-annotation-tool", tool); | ||
| 488 | }; | ||
| 489 | |||
| 490 | const removeSelected = (target: SelectedElement): void => { | ||
| 491 | if (target.element instanceof HTMLElement || target.element instanceof SVGElement) { | ||
| 492 | for (const [property, baseline] of target.baselineStyles) { | ||
| 493 | if (baseline) target.element.style.setProperty(property, baseline); | ||
| 494 | else target.element.style.removeProperty(property); | ||
| 495 | } | ||
| 496 | } | ||
| 497 | selected.delete(target.element); | ||
| 498 | target.outline.remove(); | ||
| 499 | target.label.remove(); | ||
| 500 | for (const [key, change] of styleChanges) { | ||
| 501 | if (change.targetId === target.id) styleChanges.delete(key); | ||
| 502 | } | ||
| 503 | updateStatus(); | ||
| 504 | }; | ||
| 505 | |||
| 506 | const addSelected = (element: Element): void => { | ||
| 507 | if (selected.has(element)) return; | ||
| 508 | const target: SelectedElement = { | ||
| 509 | id: nextId("element"), | ||
| 510 | element, | ||
| 511 | outline: createBox(PRIMARY, PRIMARY_FILL), | ||
| 512 | label: createLabel(), | ||
| 513 | baselineStyles: new Map(), | ||
| 514 | }; | ||
| 515 | selected.set(element, target); | ||
| 516 | root.append(target.outline, target.label); | ||
| 517 | updateSelectedVisual(target); | ||
| 518 | updateStatus(); | ||
| 519 | if (editorExpanded) { | ||
| 520 | stylePanel.style.display = "grid"; | ||
| 521 | syncStyleControls(); | ||
| 522 | } | ||
| 523 | }; | ||
| 524 | |||
| 525 | const toggleSelected = (element: Element, additive: boolean): void => { | ||
| 526 | const existing = selected.get(element); | ||
| 527 | if (existing) { | ||
| 528 | removeSelected(existing); | ||
| 529 | return; | ||
| 530 | } | ||
| 531 | if (!additive) { | ||
| 532 | for (const target of Array.from(selected.values())) removeSelected(target); | ||
| 533 | } | ||
| 534 | addSelected(element); | ||
| 535 | }; | ||
| 536 | |||
| 537 | const setStyleForSelected = (property: string, value: string): void => { | ||
| 538 | for (const target of selected.values()) { | ||
| 539 | if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) | ||
| 540 | continue; | ||
| 541 | if (!target.baselineStyles.has(property)) { | ||
| 542 | target.baselineStyles.set(property, target.element.style.getPropertyValue(property)); | ||
| 543 | } | ||
| 544 | const key = `${target.id}:${property}`; | ||
| 545 | const previousValue = | ||
| 546 | styleChanges.get(key)?.previousValue ?? | ||
| 547 | getComputedStyle(target.element).getPropertyValue(property).trim(); | ||
| 548 | target.element.style.setProperty(property, value, "important"); | ||
| 549 | styleChanges.set(key, { | ||
| 550 | targetId: target.id, | ||
| 551 | selector: null, | ||
| 552 | property, | ||
| 553 | previousValue, | ||
| 554 | value, | ||
| 555 | }); | ||
| 556 | updateSelectedVisual(target); | ||
| 557 | } | ||
| 558 | }; | ||
| 559 | |||
| 560 | const textSection = createStyleSection(); | ||
| 561 | const colorsSection = createStyleSection(); | ||
| 562 | const bordersSection = createStyleSection(); | ||
| 563 | const sizingSection = createStyleSection(); | ||
| 564 | stylePanel.append(textSection, colorsSection, bordersSection, sizingSection); | ||
| 565 | |||
| 566 | const fontFamily = document.createElement("select"); | ||
| 567 | for (const value of ["inherit", "system-ui", "sans-serif", "serif", "monospace"]) { | ||
| 568 | const option = document.createElement("option"); | ||
| 569 | option.value = value; | ||
| 570 | option.textContent = value; | ||
| 571 | fontFamily.appendChild(option); | ||
| 572 | } | ||
| 573 | fontFamily.addEventListener("change", () => setStyleForSelected("font-family", fontFamily.value)); | ||
| 574 | textSection.appendChild(createField("Font", fontFamily)); | ||
| 575 | |||
| 576 | const fontSize = createUnitInput("px", "16"); | ||
| 577 | fontSize.min = "1"; | ||
| 578 | fontSize.max = "300"; | ||
| 579 | fontSize.addEventListener("input", () => { | ||
| 580 | if (fontSize.value) setStyleForSelected("font-size", `${fontSize.value}px`); | ||
| 581 | }); | ||
| 582 | textSection.appendChild(createField("Font size", fontSize)); | ||
| 583 | |||
| 584 | const fontWeight = document.createElement("select"); | ||
| 585 | for (const value of ["300", "400", "500", "600", "700", "800", "900"]) { | ||
| 586 | const option = document.createElement("option"); | ||
| 587 | option.value = value; | ||
| 588 | option.textContent = value; | ||
| 589 | fontWeight.appendChild(option); | ||
| 590 | } | ||
| 591 | fontWeight.addEventListener("change", () => setStyleForSelected("font-weight", fontWeight.value)); | ||
| 592 | textSection.appendChild(createField("Font weight", fontWeight)); | ||
| 593 | |||
| 594 | const lineHeight = document.createElement("input"); | ||
| 595 | lineHeight.type = "text"; | ||
| 596 | lineHeight.placeholder = "normal / 1.4"; | ||
| 597 | lineHeight.addEventListener("change", () => { | ||
| 598 | if (lineHeight.value.trim()) setStyleForSelected("line-height", lineHeight.value.trim()); | ||
| 599 | }); | ||
| 600 | textSection.appendChild(createField("Line height", lineHeight)); | ||
| 601 | |||
| 602 | const createColorRow = ( | ||
| 603 | labelText: string, | ||
| 604 | property: string, | ||
| 605 | section: HTMLElement, | ||
| 606 | ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => { | ||
| 607 | const row = document.createElement("label"); | ||
| 608 | row.className = | ||
| 609 | "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; | ||
| 610 | const label = document.createElement("span"); | ||
| 611 | label.textContent = labelText; | ||
| 612 | const control = document.createElement("div"); | ||
| 613 | control.className = | ||
| 614 | "grid h-7 grid-cols-[22px_minmax(0,1fr)] items-center gap-1 rounded-md border border-input bg-background px-1 shadow-xs"; | ||
| 615 | const color = document.createElement("input"); | ||
| 616 | color.type = "color"; | ||
| 617 | color.setAttribute("aria-label", labelText); | ||
| 618 | color.style.cssText = | ||
| 619 | "width:20px;height:20px;padding:0;border:0;border-radius:5px;overflow:hidden;background:transparent;cursor:pointer"; | ||
| 620 | const text = document.createElement("input"); | ||
| 621 | text.type = "text"; | ||
| 622 | text.setAttribute("aria-label", `${labelText} value`); | ||
| 623 | text.className = | ||
| 624 | "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none"; | ||
| 625 | color.addEventListener("input", () => { | ||
| 626 | text.value = color.value; | ||
| 627 | setStyleForSelected(property, color.value); | ||
| 628 | }); | ||
| 629 | text.addEventListener("change", () => { | ||
| 630 | const value = text.value.trim(); | ||
| 631 | if (!value) return; | ||
| 632 | setStyleForSelected(property, value); | ||
| 633 | if (/^#[0-9a-f]{6}$/i.test(value)) color.value = value; | ||
| 634 | }); | ||
| 635 | control.append(color, text); | ||
| 636 | row.append(label, control); | ||
| 637 | section.appendChild(row); | ||
| 638 | return { row, color, text }; | ||
| 639 | }; | ||
| 640 | |||
| 641 | const textColor = createColorRow("Text color", "color", colorsSection); | ||
| 642 | const backgroundColor = createColorRow("Background", "background-color", colorsSection); | ||
| 643 | |||
| 644 | const opacity = document.createElement("input"); | ||
| 645 | opacity.type = "range"; | ||
| 646 | opacity.min = "0"; | ||
| 647 | opacity.max = "1"; | ||
| 648 | opacity.step = "0.05"; | ||
| 649 | opacity.value = "1"; | ||
| 650 | opacity.style.accentColor = PRIMARY; | ||
| 651 | opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value)); | ||
| 652 | colorsSection.appendChild(createField("Opacity", opacity)); | ||
| 653 | |||
| 654 | const radius = createUnitInput("px", "0"); | ||
| 655 | radius.min = "0"; | ||
| 656 | radius.max = "300"; | ||
| 657 | radius.addEventListener("input", () => { | ||
| 658 | if (radius.value) setStyleForSelected("border-radius", `${radius.value}px`); | ||
| 659 | }); | ||
| 660 | bordersSection.appendChild(createField("Radius", radius)); | ||
| 661 | |||
| 662 | const borderColor = createColorRow("Border color", "border-color", bordersSection); | ||
| 663 | |||
| 664 | const borderWidth = createUnitInput("px", "0"); | ||
| 665 | borderWidth.min = "0"; | ||
| 666 | borderWidth.max = "100"; | ||
| 667 | borderWidth.addEventListener("input", () => { | ||
| 668 | if (borderWidth.value) { | ||
| 669 | setStyleForSelected("border-style", "solid"); | ||
| 670 | setStyleForSelected("border-width", `${borderWidth.value}px`); | ||
| 671 | } | ||
| 672 | }); | ||
| 673 | bordersSection.appendChild(createField("Border width", borderWidth)); | ||
| 674 | |||
| 675 | const dimensions = document.createElement("div"); | ||
| 676 | dimensions.style.cssText = | ||
| 677 | "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center"; | ||
| 678 | const dimensionLabel = document.createElement("div"); | ||
| 679 | dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground"; | ||
| 680 | dimensionLabel.innerHTML = "<span>Width</span><span>Height</span>"; | ||
| 681 | const dimensionControls = document.createElement("div"); | ||
| 682 | dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px"; | ||
| 683 | const widthInput = createUnitInput("px", "auto"); | ||
| 684 | const heightInput = createUnitInput("px", "auto"); | ||
| 685 | styleControl(widthInput); | ||
| 686 | styleControl(heightInput); | ||
| 687 | const aspectLock = createButton("", "Lock aspect ratio"); | ||
| 688 | aspectLock.setAttribute("aria-pressed", "true"); | ||
| 689 | aspectLock.style.cssText += | ||
| 690 | ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0"; | ||
| 691 | aspectLock.className += " bg-primary/10 text-primary"; | ||
| 692 | dimensionControls.append( | ||
| 693 | createUnitControl(widthInput), | ||
| 694 | createUnitControl(heightInput), | ||
| 695 | aspectLock, | ||
| 696 | ); | ||
| 697 | dimensions.append(dimensionLabel, dimensionControls); | ||
| 698 | sizingSection.appendChild(dimensions); | ||
| 699 | |||
| 700 | let aspectLocked = true; | ||
| 701 | let aspectRatio = 1; | ||
| 702 | const refreshAspectButton = (): void => { | ||
| 703 | aspectLock.innerHTML = aspectLocked | ||
| 704 | ? '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="M8 6.5 9.5 5A3.5 3.5 0 0 1 14.5 10l-1.5 1.5M12 13.5 10.5 15A3.5 3.5 0 0 1 5.5 10L7 8.5M7.5 12.5l5-5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>' | ||
| 705 | : '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m6 6 8 8M8 6.5 9.5 5A3.5 3.5 0 0 1 14 9M12 13.5 10.5 15A3.5 3.5 0 0 1 6 11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>'; | ||
| 706 | aspectLock.setAttribute("aria-pressed", String(aspectLocked)); | ||
| 707 | aspectLock.classList.toggle("bg-primary/10", aspectLocked); | ||
| 708 | aspectLock.classList.toggle("text-primary", aspectLocked); | ||
| 709 | aspectLock.classList.toggle("bg-muted", !aspectLocked); | ||
| 710 | aspectLock.classList.toggle("text-muted-foreground", !aspectLocked); | ||
| 711 | }; | ||
| 712 | aspectLock.addEventListener("click", () => { | ||
| 713 | aspectLocked = !aspectLocked; | ||
| 714 | refreshAspectButton(); | ||
| 715 | }); | ||
| 716 | widthInput.addEventListener("input", () => { | ||
| 717 | const width = Number(widthInput.value); | ||
| 718 | if (!Number.isFinite(width) || width <= 0) return; | ||
| 719 | setStyleForSelected("width", `${width}px`); | ||
| 720 | if (aspectLocked && aspectRatio > 0) { | ||
| 721 | const height = Math.max(1, Math.round(width / aspectRatio)); | ||
| 722 | heightInput.value = String(height); | ||
| 723 | setStyleForSelected("height", `${height}px`); | ||
| 724 | } | ||
| 725 | }); | ||
| 726 | heightInput.addEventListener("input", () => { | ||
| 727 | const height = Number(heightInput.value); | ||
| 728 | if (!Number.isFinite(height) || height <= 0) return; | ||
| 729 | setStyleForSelected("height", `${height}px`); | ||
| 730 | if (aspectLocked && aspectRatio > 0) { | ||
| 731 | const width = Math.max(1, Math.round(height * aspectRatio)); | ||
| 732 | widthInput.value = String(width); | ||
| 733 | setStyleForSelected("width", `${width}px`); | ||
| 734 | } | ||
| 735 | }); | ||
| 736 | refreshAspectButton(); | ||
| 737 | |||
| 738 | const addSpacingField = ( | ||
| 739 | label: string, | ||
| 740 | property: string, | ||
| 741 | placeholder: string, | ||
| 742 | ): HTMLInputElement => { | ||
| 743 | const input = document.createElement("input"); | ||
| 744 | input.type = "text"; | ||
| 745 | input.placeholder = placeholder; | ||
| 746 | input.addEventListener("change", () => { | ||
| 747 | if (input.value.trim()) setStyleForSelected(property, input.value.trim()); | ||
| 748 | }); | ||
| 749 | sizingSection.appendChild(createField(label, input)); | ||
| 750 | return input; | ||
| 751 | }; | ||
| 752 | const padding = addSpacingField("Padding", "padding", "0 0 0 0"); | ||
| 753 | const margin = addSpacingField("Margin", "margin", "0 0 0 0"); | ||
| 754 | const gap = addSpacingField("Gap", "gap", "0px"); | ||
| 755 | |||
| 756 | const syncStyleControls = (): void => { | ||
| 757 | const first = selected.values().next().value as SelectedElement | undefined; | ||
| 758 | if (!first) return; | ||
| 759 | const computed = getComputedStyle(first.element); | ||
| 760 | const rect = first.element.getBoundingClientRect(); | ||
| 761 | aspectRatio = rect.height > 0 ? rect.width / rect.height : 1; | ||
| 762 | widthInput.value = String(Math.round(rect.width)); | ||
| 763 | heightInput.value = String(Math.round(rect.height)); | ||
| 764 | fontSize.value = String(Math.round(Number.parseFloat(computed.fontSize) || 16)); | ||
| 765 | fontWeight.value = computed.fontWeight.match(/^[0-9]+$/) ? computed.fontWeight : "400"; | ||
| 766 | lineHeight.value = computed.lineHeight; | ||
| 767 | fontFamily.value = Array.from(fontFamily.options).some( | ||
| 768 | (option) => option.value === computed.fontFamily, | ||
| 769 | ) | ||
| 770 | ? computed.fontFamily | ||
| 771 | : "inherit"; | ||
| 772 | textColor.text.value = computed.color; | ||
| 773 | backgroundColor.text.value = computed.backgroundColor; | ||
| 774 | borderColor.text.value = computed.borderColor; | ||
| 775 | opacity.value = computed.opacity; | ||
| 776 | radius.value = String(Math.round(Number.parseFloat(computed.borderRadius) || 0)); | ||
| 777 | borderWidth.value = String(Math.round(Number.parseFloat(computed.borderWidth) || 0)); | ||
| 778 | padding.value = computed.padding; | ||
| 779 | margin.value = computed.margin; | ||
| 780 | gap.value = computed.gap === "normal" ? "0px" : computed.gap; | ||
| 781 | }; | ||
| 782 | |||
| 783 | const tools: ReadonlyArray<[AnnotationTool, string, string]> = [ | ||
| 784 | ["select", "Select", "Select elements (V)"], | ||
| 785 | ["marquee", "Region", "Draw a region or marquee-select elements (R)"], | ||
| 786 | ["draw", "Draw", "Draw freehand (D)"], | ||
| 787 | ["erase", "Erase", "Remove an annotation target (E)"], | ||
| 788 | ]; | ||
| 789 | for (const [candidate, label, title] of tools) { | ||
| 790 | const button = createButton(label, title); | ||
| 791 | button.className += " h-8 px-2.5 text-sm"; | ||
| 792 | button.addEventListener("click", () => { | ||
| 793 | tool = candidate; | ||
| 794 | refreshToolButtons(); | ||
| 795 | }); | ||
| 796 | toolButtons.set(candidate, button); | ||
| 797 | toolbar.appendChild(button); | ||
| 798 | } | ||
| 799 | |||
| 800 | const clampEditorPosition = (left: number, top: number): { left: number; top: number } => { | ||
| 801 | const margin = 8; | ||
| 802 | const rect = editor.getBoundingClientRect(); | ||
| 803 | return { | ||
| 804 | left: Math.min( | ||
| 805 | Math.max(margin, left), | ||
| 806 | Math.max(margin, window.innerWidth - rect.width - margin), | ||
| 807 | ), | ||
| 808 | top: Math.min( | ||
| 809 | Math.max(margin, top), | ||
| 810 | Math.max(margin, window.innerHeight - rect.height - margin), | ||
| 811 | ), | ||
| 812 | }; | ||
| 813 | }; | ||
| 814 | |||
| 815 | const applyEditorPosition = (position: { left: number; top: number }): void => { | ||
| 816 | const clamped = clampEditorPosition(position.left, position.top); | ||
| 817 | editor.style.left = `${clamped.left}px`; | ||
| 818 | editor.style.top = `${clamped.top}px`; | ||
| 819 | editor.style.right = "auto"; | ||
| 820 | editor.style.bottom = "auto"; | ||
| 821 | if (editorExpanded) editorPosition = clamped; | ||
| 822 | }; | ||
| 823 | |||
| 824 | const getAnnotationBounds = (): PreviewAnnotationRect | null => | ||
| 825 | unionRects( | ||
| 826 | [ | ||
| 827 | ...Array.from(selected.values(), (target) => | ||
| 828 | rectFromDomRect(target.element.getBoundingClientRect()), | ||
| 829 | ), | ||
| 830 | ...regions.map((region) => region.rect), | ||
| 831 | ...strokes.map((stroke) => stroke.bounds), | ||
| 832 | ], | ||
| 833 | 0, | ||
| 834 | ); | ||
| 835 | |||
| 836 | const positionCompactEditor = (): void => { | ||
| 837 | const bounds = getAnnotationBounds(); | ||
| 838 | if (!bounds) return; | ||
| 839 | const editorRect = editor.getBoundingClientRect(); | ||
| 840 | const gap = 8; | ||
| 841 | const candidates = [ | ||
| 842 | { left: bounds.x + bounds.width + gap, top: bounds.y }, | ||
| 843 | { left: bounds.x - editorRect.width - gap, top: bounds.y }, | ||
| 844 | { | ||
| 845 | left: bounds.x + bounds.width - editorRect.width, | ||
| 846 | top: bounds.y + bounds.height + gap, | ||
| 847 | }, | ||
| 848 | { | ||
| 849 | left: bounds.x + bounds.width - editorRect.width, | ||
| 850 | top: bounds.y - editorRect.height - gap, | ||
| 851 | }, | ||
| 852 | ]; | ||
| 853 | const overflow = (position: { left: number; top: number }): number => | ||
| 854 | Math.max(0, -position.left) + | ||
| 855 | Math.max(0, -position.top) + | ||
| 856 | Math.max(0, position.left + editorRect.width - window.innerWidth) + | ||
| 857 | Math.max(0, position.top + editorRect.height - window.innerHeight); | ||
| 858 | const best = candidates.reduce((current, candidate) => | ||
| 859 | overflow(candidate) < overflow(current) ? candidate : current, | ||
| 860 | ); | ||
| 861 | applyEditorPosition(best); | ||
| 862 | }; | ||
| 863 | |||
| 864 | function queueEditorLayout(): void { | ||
| 865 | if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); | ||
| 866 | editorLayoutFrame = window.requestAnimationFrame(() => { | ||
| 867 | editorLayoutFrame = null; | ||
| 868 | if (editor.style.display === "none") return; | ||
| 869 | if (editorExpanded && editorPosition) applyEditorPosition(editorPosition); | ||
| 870 | else positionCompactEditor(); | ||
| 871 | }); | ||
| 872 | } | ||
| 873 | |||
| 874 | adjust.addEventListener("click", () => { | ||
| 875 | if (selected.size === 0) return; | ||
| 876 | if (!editorExpanded) { | ||
| 877 | const rect = editor.getBoundingClientRect(); | ||
| 878 | editorExpanded = true; | ||
| 879 | editorPosition = { left: rect.left, top: rect.top }; | ||
| 880 | stylePanel.style.display = selected.size > 0 ? "grid" : "none"; | ||
| 881 | dragHandle.style.display = "block"; | ||
| 882 | adjust.setAttribute("aria-expanded", "true"); | ||
| 883 | adjust.title = "Collapse annotation editor"; | ||
| 884 | adjust.setAttribute("aria-label", "Collapse annotation editor"); | ||
| 885 | if (selected.size > 0) syncStyleControls(); | ||
| 886 | } else { | ||
| 887 | editorExpanded = false; | ||
| 888 | editorPosition = null; | ||
| 889 | stylePanel.style.display = "none"; | ||
| 890 | dragHandle.style.display = "none"; | ||
| 891 | adjust.setAttribute("aria-expanded", "false"); | ||
| 892 | adjust.title = "Expand annotation editor"; | ||
| 893 | adjust.setAttribute("aria-label", "Expand annotation editor"); | ||
| 894 | } | ||
| 895 | queueEditorLayout(); | ||
| 896 | }); | ||
| 897 | |||
| 898 | const onEditorPointerDown = (event: PointerEvent): void => { | ||
| 899 | if (event.button !== 0 || !editorExpanded) return; | ||
| 900 | const rect = editor.getBoundingClientRect(); | ||
| 901 | editorDrag = { | ||
| 902 | pointerId: event.pointerId, | ||
| 903 | offsetX: event.clientX - rect.left, | ||
| 904 | offsetY: event.clientY - rect.top, | ||
| 905 | }; | ||
| 906 | dragHandle.setPointerCapture(event.pointerId); | ||
| 907 | dragHandle.style.cursor = "grabbing"; | ||
| 908 | event.preventDefault(); | ||
| 909 | event.stopPropagation(); | ||
| 910 | }; | ||
| 911 | |||
| 912 | const onEditorPointerMove = (event: PointerEvent): void => { | ||
| 913 | if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; | ||
| 914 | applyEditorPosition({ | ||
| 915 | left: event.clientX - editorDrag.offsetX, | ||
| 916 | top: event.clientY - editorDrag.offsetY, | ||
| 917 | }); | ||
| 918 | event.preventDefault(); | ||
| 919 | event.stopPropagation(); | ||
| 920 | }; | ||
| 921 | |||
| 922 | const onEditorPointerUp = (event: PointerEvent): void => { | ||
| 923 | if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; | ||
| 924 | editorDrag = null; | ||
| 925 | dragHandle.style.cursor = "grab"; | ||
| 926 | if (dragHandle.hasPointerCapture(event.pointerId)) | ||
| 927 | dragHandle.releasePointerCapture(event.pointerId); | ||
| 928 | event.preventDefault(); | ||
| 929 | event.stopPropagation(); | ||
| 930 | }; | ||
| 931 | dragHandle.addEventListener("pointerdown", onEditorPointerDown); | ||
| 932 | dragHandle.addEventListener("pointermove", onEditorPointerMove); | ||
| 933 | dragHandle.addEventListener("pointerup", onEditorPointerUp); | ||
| 934 | dragHandle.addEventListener("pointercancel", onEditorPointerUp); | ||
| 935 | |||
| 936 | const repaint = (): void => { | ||
| 937 | for (const target of selected.values()) updateSelectedVisual(target); | ||
| 938 | queueEditorLayout(); | ||
| 939 | }; | ||
| 940 | |||
| 941 | const removeTargetAtPoint = (x: number, y: number): boolean => { | ||
| 942 | for (const target of Array.from(selected.values()).toReversed()) { | ||
| 943 | const rect = target.element.getBoundingClientRect(); | ||
| 944 | if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { | ||
| 945 | removeSelected(target); | ||
| 946 | return true; | ||
| 947 | } | ||
| 948 | } | ||
| 949 | const regionIndex = regions.findIndex( | ||
| 950 | (region) => | ||
| 951 | x >= region.rect.x && | ||
| 952 | x <= region.rect.x + region.rect.width && | ||
| 953 | y >= region.rect.y && | ||
| 954 | y <= region.rect.y + region.rect.height, | ||
| 955 | ); | ||
| 956 | if (regionIndex >= 0) { | ||
| 957 | const [removed] = regions.splice(regionIndex, 1); | ||
| 958 | root.querySelector(`[data-region-id="${removed?.id}"]`)?.remove(); | ||
| 959 | updateStatus(); | ||
| 960 | return true; | ||
| 961 | } | ||
| 962 | const strokeIndex = strokes.findIndex( | ||
| 963 | (stroke) => | ||
| 964 | x >= stroke.bounds.x && | ||
| 965 | x <= stroke.bounds.x + stroke.bounds.width && | ||
| 966 | y >= stroke.bounds.y && | ||
| 967 | y <= stroke.bounds.y + stroke.bounds.height, | ||
| 968 | ); | ||
| 969 | if (strokeIndex >= 0) { | ||
| 970 | const [removed] = strokes.splice(strokeIndex, 1); | ||
| 971 | svg.querySelector(`[data-stroke-id="${removed?.id}"]`)?.remove(); | ||
| 972 | updateStatus(); | ||
| 973 | return true; | ||
| 974 | } | ||
| 975 | return false; | ||
| 976 | }; | ||
| 977 | |||
| 978 | const selectElementsInRect = (rect: PreviewAnnotationRect): number => { | ||
| 979 | const candidates = Array.from(document.querySelectorAll("body *")) | ||
| 980 | .filter((element) => !isAnnotationNode(element)) | ||
| 981 | .map((element) => ({ element, rect: element.getBoundingClientRect() })) | ||
| 982 | .filter(({ rect: candidate }) => { | ||
| 983 | if (candidate.width < 2 || candidate.height < 2) return false; | ||
| 984 | return !( | ||
| 985 | candidate.right < rect.x || | ||
| 986 | candidate.left > rect.x + rect.width || | ||
| 987 | candidate.bottom < rect.y || | ||
| 988 | candidate.top > rect.y + rect.height | ||
| 989 | ); | ||
| 990 | }) | ||
| 991 | .filter(({ element, rect: candidate }) => { | ||
| 992 | const centerX = candidate.left + candidate.width / 2; | ||
| 993 | const centerY = candidate.top + candidate.height / 2; | ||
| 994 | return ( | ||
| 995 | centerX >= rect.x && | ||
| 996 | centerX <= rect.x + rect.width && | ||
| 997 | centerY >= rect.y && | ||
| 998 | centerY <= rect.y + rect.height && | ||
| 999 | (element.children.length === 0 || | ||
| 1000 | element instanceof HTMLButtonElement || | ||
| 1001 | element instanceof HTMLAnchorElement || | ||
| 1002 | element.getAttribute("role") === "button") | ||
| 1003 | ); | ||
| 1004 | }) | ||
| 1005 | .sort( | ||
| 1006 | (left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height, | ||
| 1007 | ) | ||
| 1008 | .slice(0, MAX_MARQUEE_ELEMENTS); | ||
| 1009 | for (const candidate of candidates) addSelected(candidate.element); | ||
| 1010 | return candidates.length; | ||
| 1011 | }; | ||
| 1012 | |||
| 1013 | const clearHoverOutline = (): void => { | ||
| 1014 | hoverOutline.style.display = "none"; | ||
| 1015 | }; | ||
| 1016 | |||
| 1017 | const onPointerMove = (event: PointerEvent): void => { | ||
| 1018 | if (isAnnotationNode(event.target as Element)) { | ||
| 1019 | clearHoverOutline(); | ||
| 1020 | return; | ||
| 1021 | } | ||
| 1022 | if (tool === "select" && dragStart === null) { | ||
| 1023 | const target = pickFromPoint(event.clientX, event.clientY); | ||
| 1024 | if (target) positionBox(hoverOutline, rectFromDomRect(target.getBoundingClientRect())); | ||
| 1025 | else clearHoverOutline(); | ||
| 1026 | return; | ||
| 1027 | } | ||
| 1028 | clearHoverOutline(); | ||
| 1029 | if (tool === "marquee" && dragStart) { | ||
| 1030 | positionBox( | ||
| 1031 | marqueeBox, | ||
| 1032 | normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY), | ||
| 1033 | ); | ||
| 1034 | return; | ||
| 1035 | } | ||
| 1036 | if (tool === "draw" && activeStroke) { | ||
| 1037 | activeStroke.target.points = [ | ||
| 1038 | ...activeStroke.target.points, | ||
| 1039 | { x: event.clientX, y: event.clientY }, | ||
| 1040 | ]; | ||
| 1041 | activeStroke.target.bounds = strokeBounds( | ||
| 1042 | activeStroke.target.points, | ||
| 1043 | activeStroke.target.width, | ||
| 1044 | ); | ||
| 1045 | activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points)); | ||
| 1046 | } | ||
| 1047 | }; | ||
| 1048 | |||
| 1049 | const onPointerDown = (event: PointerEvent): void => { | ||
| 1050 | if (event.button !== 0 || isAnnotationNode(event.target as Element)) return; | ||
| 1051 | event.preventDefault(); | ||
| 1052 | event.stopPropagation(); | ||
| 1053 | if (tool === "select") { | ||
| 1054 | const target = pickFromPoint(event.clientX, event.clientY); | ||
| 1055 | if (target) toggleSelected(target, event.shiftKey); | ||
| 1056 | return; | ||
| 1057 | } | ||
| 1058 | if (tool === "erase") { | ||
| 1059 | removeTargetAtPoint(event.clientX, event.clientY); | ||
| 1060 | return; | ||
| 1061 | } | ||
| 1062 | dragStart = { x: event.clientX, y: event.clientY }; | ||
| 1063 | if (tool === "draw") { | ||
| 1064 | const stroke: PreviewAnnotationStrokeTarget = { | ||
| 1065 | id: nextId("stroke"), | ||
| 1066 | color: annotationTheme?.primary ?? "#2563eb", | ||
| 1067 | width: 4, | ||
| 1068 | points: [dragStart], | ||
| 1069 | bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, | ||
| 1070 | }; | ||
| 1071 | const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); | ||
| 1072 | path.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 1073 | path.setAttribute("data-stroke-id", stroke.id); | ||
| 1074 | path.setAttribute("fill", "none"); | ||
| 1075 | path.setAttribute("stroke", stroke.color); | ||
| 1076 | path.setAttribute("stroke-width", String(stroke.width)); | ||
| 1077 | path.setAttribute("stroke-linecap", "round"); | ||
| 1078 | path.setAttribute("stroke-linejoin", "round"); | ||
| 1079 | svg.appendChild(path); | ||
| 1080 | activeStroke = { target: stroke, path }; | ||
| 1081 | } | ||
| 1082 | }; | ||
| 1083 | |||
| 1084 | const onPointerUp = (event: PointerEvent): void => { | ||
| 1085 | if (!dragStart) return; | ||
| 1086 | event.preventDefault(); | ||
| 1087 | event.stopPropagation(); | ||
| 1088 | if (tool === "marquee") { | ||
| 1089 | const rect = normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY); | ||
| 1090 | marqueeBox.style.display = "none"; | ||
| 1091 | if (isUsableRect(rect)) { | ||
| 1092 | const found = selectElementsInRect(rect); | ||
| 1093 | if (found === 0) { | ||
| 1094 | const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; | ||
| 1095 | regions.push(region); | ||
| 1096 | const regionBox = createBox( | ||
| 1097 | PRIMARY, | ||
| 1098 | "color-mix(in srgb, var(--t3-primary) 6%, transparent)", | ||
| 1099 | ); | ||
| 1100 | regionBox.setAttribute("data-region-id", region.id); | ||
| 1101 | positionBox(regionBox, rect); | ||
| 1102 | root.appendChild(regionBox); | ||
| 1103 | } | ||
| 1104 | } | ||
| 1105 | } else if (tool === "draw" && activeStroke) { | ||
| 1106 | if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target); | ||
| 1107 | else activeStroke.path.remove(); | ||
| 1108 | activeStroke = null; | ||
| 1109 | } | ||
| 1110 | dragStart = null; | ||
| 1111 | updateStatus(); | ||
| 1112 | }; | ||
| 1113 | |||
| 1114 | const onClick = (event: MouseEvent): void => { | ||
| 1115 | if (isAnnotationNode(event.target as Element)) return; | ||
| 1116 | event.preventDefault(); | ||
| 1117 | event.stopPropagation(); | ||
| 1118 | }; | ||
| 1119 | |||
| 1120 | const onPointerOut = (event: PointerEvent): void => { | ||
| 1121 | if (event.relatedTarget === null) clearHoverOutline(); | ||
| 1122 | }; | ||
| 1123 | |||
| 1124 | const onWindowBlur = (): void => { | ||
| 1125 | clearHoverOutline(); | ||
| 1126 | }; | ||
| 1127 | |||
| 1128 | const restoreStyles = (): void => { | ||
| 1129 | for (const target of selected.values()) { | ||
| 1130 | if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) | ||
| 1131 | continue; | ||
| 1132 | for (const [property, baseline] of target.baselineStyles) { | ||
| 1133 | if (baseline) target.element.style.setProperty(property, baseline); | ||
| 1134 | else target.element.style.removeProperty(property); | ||
| 1135 | } | ||
| 1136 | } | ||
| 1137 | }; | ||
| 1138 | |||
| 1139 | const teardown = (notifyMain: boolean): void => { | ||
| 1140 | if (finished) return; | ||
| 1141 | finished = true; | ||
| 1142 | restoreStyles(); | ||
| 1143 | window.removeEventListener("pointermove", onPointerMove, true); | ||
| 1144 | window.removeEventListener("pointerdown", onPointerDown, true); | ||
| 1145 | window.removeEventListener("pointerup", onPointerUp, true); | ||
| 1146 | window.removeEventListener("pointerout", onPointerOut, true); | ||
| 1147 | window.removeEventListener("click", onClick, true); | ||
| 1148 | window.removeEventListener("blur", onWindowBlur); | ||
| 1149 | window.removeEventListener("keydown", onKeyDown, true); | ||
| 1150 | window.removeEventListener("scroll", repaint, true); | ||
| 1151 | window.removeEventListener("resize", repaint); | ||
| 1152 | dragHandle.removeEventListener("pointerdown", onEditorPointerDown); | ||
| 1153 | dragHandle.removeEventListener("pointermove", onEditorPointerMove); | ||
| 1154 | dragHandle.removeEventListener("pointerup", onEditorPointerUp); | ||
| 1155 | dragHandle.removeEventListener("pointercancel", onEditorPointerUp); | ||
| 1156 | if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); | ||
| 1157 | ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); | ||
| 1158 | ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); | ||
| 1159 | document.documentElement.removeAttribute("data-t3code-annotation-tool"); | ||
| 1160 | cursorStyle.remove(); | ||
| 1161 | host.remove(); | ||
| 1162 | activeSession = null; | ||
| 1163 | if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); | ||
| 1164 | }; | ||
| 1165 | |||
| 1166 | const onCancel = (): void => teardown(false); | ||
| 1167 | const onCaptured = (): void => teardown(false); | ||
| 1168 | const onKeyDown = (event: KeyboardEvent): void => { | ||
| 1169 | if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return; | ||
| 1170 | if (event.key === "Escape") { | ||
| 1171 | event.preventDefault(); | ||
| 1172 | event.stopPropagation(); | ||
| 1173 | teardown(true); | ||
| 1174 | return; | ||
| 1175 | } | ||
| 1176 | if (event.key === "v") tool = "select"; | ||
| 1177 | else if (event.key === "r") tool = "marquee"; | ||
| 1178 | else if (event.key === "d") tool = "draw"; | ||
| 1179 | else if (event.key === "e") tool = "erase"; | ||
| 1180 | else return; | ||
| 1181 | refreshToolButtons(); | ||
| 1182 | }; | ||
| 1183 | |||
| 1184 | submit.addEventListener("click", () => { | ||
| 1185 | if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) | ||
| 1186 | return; | ||
| 1187 | pendingCapture = true; | ||
| 1188 | submit.disabled = true; | ||
| 1189 | submit.textContent = "Capturing…"; | ||
| 1190 | void Promise.all( | ||
| 1191 | Array.from(selected.values()).map(async (target) => { | ||
| 1192 | const element = await captureElement(target.element); | ||
| 1193 | if (!element) return null; | ||
| 1194 | for (const change of styleChanges.values()) { | ||
| 1195 | if (change.targetId === target.id) change.selector = element.selector; | ||
| 1196 | } | ||
| 1197 | return { | ||
| 1198 | id: target.id, | ||
| 1199 | element, | ||
| 1200 | rect: rectFromDomRect(target.element.getBoundingClientRect()), | ||
| 1201 | }; | ||
| 1202 | }), | ||
| 1203 | ).then((captured) => { | ||
| 1204 | const elements = captured.filter((target) => target !== null); | ||
| 1205 | const annotation: PreviewAnnotationPayload = { | ||
| 1206 | id: nextId("annotation"), | ||
| 1207 | pageUrl: location.href, | ||
| 1208 | pageTitle: document.title?.trim() || null, | ||
| 1209 | comment: comment.value.trim(), | ||
| 1210 | elements, | ||
| 1211 | regions: [...regions], | ||
| 1212 | strokes: [...strokes], | ||
| 1213 | styleChanges: Array.from(styleChanges.values()), | ||
| 1214 | screenshot: null, | ||
| 1215 | createdAt: new Date().toISOString(), | ||
| 1216 | }; | ||
| 1217 | editor.style.display = "none"; | ||
| 1218 | toolbar.style.display = "none"; | ||
| 1219 | hoverOutline.style.display = "none"; | ||
| 1220 | const screenshotRect = unionRects([ | ||
| 1221 | ...elements.map((target) => target.rect), | ||
| 1222 | ...regions.map((region) => region.rect), | ||
| 1223 | ...strokes.map((stroke) => stroke.bounds), | ||
| 1224 | ]); | ||
| 1225 | ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect); | ||
| 1226 | }); | ||
| 1227 | }); | ||
| 1228 | comment.addEventListener("keydown", (event) => { | ||
| 1229 | if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return; | ||
| 1230 | event.preventDefault(); | ||
| 1231 | submit.click(); | ||
| 1232 | }); | ||
| 1233 | |||
| 1234 | window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); | ||
| 1235 | window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false }); | ||
| 1236 | window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false }); | ||
| 1237 | window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true }); | ||
| 1238 | window.addEventListener("click", onClick, { capture: true, passive: false }); | ||
| 1239 | window.addEventListener("blur", onWindowBlur); | ||
| 1240 | window.addEventListener("keydown", onKeyDown, { capture: true }); | ||
| 1241 | window.addEventListener("scroll", repaint, { capture: true, passive: true }); | ||
| 1242 | window.addEventListener("resize", repaint, { passive: true }); | ||
| 1243 | ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); | ||
| 1244 | ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); | ||
| 1245 | document.documentElement.appendChild(host); | ||
| 1246 | refreshToolButtons(); | ||
| 1247 | updateStatus(); | ||
| 1248 | activeSession = { | ||
| 1249 | teardown, | ||
| 1250 | applyTheme: (theme) => applyAnnotationTheme(host, theme), | ||
| 1251 | }; | ||
| 1252 | } | ||
| 1253 | |||
| 1254 | ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => { | ||
| 1255 | if (theme) annotationTheme = theme; | ||
| 1256 | startAnnotation(); | ||
| 1257 | }); | ||
| 1258 | ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { | ||
| 1259 | annotationTheme = theme; | ||
| 1260 | activeSession?.applyTheme(theme); | ||
| 1261 | }); | ||
| 1262 | ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); |
apps/desktop/src/preview-view-manager.test.ts deleted-283| ... | @@ -1,283 +0,0 @@ | ||
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | |||
| 3 | const fromId = vi.fn(() => null); | ||
| 4 | const mkdir = vi.fn(async () => undefined); | ||
| 5 | const writeFile = vi.fn(async () => undefined); | ||
| 6 | const showItemInFolder = vi.fn(); | ||
| 7 | const writeImage = vi.fn(); | ||
| 8 | const createFromPath = vi.fn(() => ({ isEmpty: () => false })); | ||
| 9 | const webviewSend = vi.fn(); | ||
| 10 | |||
| 11 | vi.mock("node:fs/promises", () => ({ mkdir, writeFile })); | ||
| 12 | |||
| 13 | vi.mock("electron", () => ({ | ||
| 14 | clipboard: { | ||
| 15 | writeImage, | ||
| 16 | }, | ||
| 17 | nativeImage: { | ||
| 18 | createFromPath, | ||
| 19 | }, | ||
| 20 | session: { | ||
| 21 | fromPartition: vi.fn(), | ||
| 22 | }, | ||
| 23 | shell: { | ||
| 24 | showItemInFolder, | ||
| 25 | }, | ||
| 26 | webContents: { | ||
| 27 | fromId, | ||
| 28 | }, | ||
| 29 | })); | ||
| 30 | |||
| 31 | describe("PreviewViewManager automation status", () => { | ||
| 32 | beforeEach(() => { | ||
| 33 | fromId.mockClear(); | ||
| 34 | mkdir.mockClear(); | ||
| 35 | writeFile.mockClear(); | ||
| 36 | showItemInFolder.mockClear(); | ||
| 37 | writeImage.mockClear(); | ||
| 38 | createFromPath.mockClear(); | ||
| 39 | webviewSend.mockClear(); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("reports an unregistered webview as temporarily unavailable", async () => { | ||
| 43 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | ||
| 44 | const manager = new PreviewViewManager(); | ||
| 45 | |||
| 46 | expect(manager.automationStatus("tab_1")).toEqual({ | ||
| 47 | available: false, | ||
| 48 | visible: true, | ||
| 49 | tabId: "tab_1", | ||
| 50 | url: null, | ||
| 51 | title: null, | ||
| 52 | loading: false, | ||
| 53 | }); | ||
| 54 | |||
| 55 | manager.createTab("tab_1"); | ||
| 56 | |||
| 57 | expect(manager.automationStatus("tab_1")).toEqual({ | ||
| 58 | available: false, | ||
| 59 | visible: true, | ||
| 60 | tabId: "tab_1", | ||
| 61 | url: null, | ||
| 62 | title: null, | ||
| 63 | loading: false, | ||
| 64 | }); | ||
| 65 | expect(fromId).not.toHaveBeenCalled(); | ||
| 66 | }); | ||
| 67 | |||
| 68 | it("captures a PNG screenshot into browser artifacts", async () => { | ||
| 69 | const png = Buffer.from("preview-png"); | ||
| 70 | const capturePage = vi.fn(async () => ({ toPNG: () => png })); | ||
| 71 | const listeners = new Map<string, (...args: never[]) => void>(); | ||
| 72 | fromId.mockReturnValue({ | ||
| 73 | id: 42, | ||
| 74 | isDestroyed: () => false, | ||
| 75 | getType: () => "webview", | ||
| 76 | getURL: () => "https://example.com:8443/path?query=value", | ||
| 77 | getTitle: () => "Example", | ||
| 78 | isLoading: () => false, | ||
| 79 | getZoomFactor: () => 1, | ||
| 80 | setZoomFactor: vi.fn(), | ||
| 81 | on: vi.fn((event: string, listener: (...args: never[]) => void) => { | ||
| 82 | listeners.set(event, listener); | ||
| 83 | }), | ||
| 84 | off: vi.fn(), | ||
| 85 | ipc: { on: vi.fn(), off: vi.fn() }, | ||
| 86 | send: webviewSend, | ||
| 87 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | ||
| 88 | setWindowOpenHandler: vi.fn(), | ||
| 89 | debugger: { | ||
| 90 | isAttached: () => false, | ||
| 91 | attach: vi.fn(), | ||
| 92 | sendCommand: vi.fn(async () => undefined), | ||
| 93 | on: vi.fn(), | ||
| 94 | off: vi.fn(), | ||
| 95 | }, | ||
| 96 | capturePage, | ||
| 97 | } as never); | ||
| 98 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | ||
| 99 | const manager = new PreviewViewManager(); | ||
| 100 | manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); | ||
| 101 | manager.createTab("tab_1"); | ||
| 102 | manager.registerWebview("tab_1", 42); | ||
| 103 | |||
| 104 | expect(webviewSend).toHaveBeenCalledWith( | ||
| 105 | "preview:annotation-theme", | ||
| 106 | expect.objectContaining({ | ||
| 107 | colorScheme: "light", | ||
| 108 | primary: "oklch(0.488 0.217 264)", | ||
| 109 | }), | ||
| 110 | ); | ||
| 111 | |||
| 112 | const artifact = await manager.captureScreenshot("tab_1"); | ||
| 113 | |||
| 114 | expect(capturePage).toHaveBeenCalledOnce(); | ||
| 115 | expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts", { | ||
| 116 | recursive: true, | ||
| 117 | }); | ||
| 118 | expect(writeFile).toHaveBeenCalledWith(artifact.path, png); | ||
| 119 | expect(artifact).toMatchObject({ | ||
| 120 | tabId: "tab_1", | ||
| 121 | mimeType: "image/png", | ||
| 122 | sizeBytes: png.byteLength, | ||
| 123 | }); | ||
| 124 | expect(artifact.path).toMatch( | ||
| 125 | /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, | ||
| 126 | ); | ||
| 127 | }); | ||
| 128 | |||
| 129 | it("reveals only files inside the configured browser artifact directory", async () => { | ||
| 130 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | ||
| 131 | const manager = new PreviewViewManager(); | ||
| 132 | manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); | ||
| 133 | |||
| 134 | manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"); | ||
| 135 | |||
| 136 | expect(showItemInFolder).toHaveBeenCalledWith( | ||
| 137 | "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png", | ||
| 138 | ); | ||
| 139 | expect(() => manager.revealArtifact("/tmp/t3/dev/settings.json")).toThrow( | ||
| 140 | "outside the configured artifact directory", | ||
| 141 | ); | ||
| 142 | }); | ||
| 143 | |||
| 144 | it("copies screenshot artifacts to the system clipboard", async () => { | ||
| 145 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | ||
| 146 | const manager = new PreviewViewManager(); | ||
| 147 | manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); | ||
| 148 | const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"; | ||
| 149 | |||
| 150 | manager.copyArtifactToClipboard(artifactPath); | ||
| 151 | |||
| 152 | expect(createFromPath).toHaveBeenCalledWith(artifactPath); | ||
| 153 | expect(writeImage).toHaveBeenCalledOnce(); | ||
| 154 | expect(() => manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json")).toThrow( | ||
| 155 | "outside the configured artifact directory", | ||
| 156 | ); | ||
| 157 | }); | ||
| 158 | |||
| 159 | it("emits the resolved pointer target before dispatching an automation click", async () => { | ||
| 160 | let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; | ||
| 161 | const activity: string[] = []; | ||
| 162 | const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => { | ||
| 163 | if (method === "Runtime.evaluate") { | ||
| 164 | return { | ||
| 165 | result: { | ||
| 166 | value: { width: 800, height: 600 }, | ||
| 167 | }, | ||
| 168 | }; | ||
| 169 | } | ||
| 170 | if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { | ||
| 171 | activity.push("mousePressed"); | ||
| 172 | humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); | ||
| 173 | } | ||
| 174 | return undefined; | ||
| 175 | }); | ||
| 176 | fromId.mockReturnValue({ | ||
| 177 | id: 42, | ||
| 178 | isDestroyed: () => false, | ||
| 179 | getType: () => "webview", | ||
| 180 | getURL: () => "https://example.com", | ||
| 181 | getTitle: () => "Example", | ||
| 182 | isLoading: () => false, | ||
| 183 | isDevToolsOpened: () => false, | ||
| 184 | getZoomFactor: () => 1, | ||
| 185 | setZoomFactor: vi.fn(), | ||
| 186 | on: vi.fn(), | ||
| 187 | off: vi.fn(), | ||
| 188 | ipc: { | ||
| 189 | on: vi.fn((channel: string, listener: typeof humanInput) => { | ||
| 190 | if (channel === "preview:human-input") humanInput = listener; | ||
| 191 | }), | ||
| 192 | off: vi.fn(), | ||
| 193 | }, | ||
| 194 | send: webviewSend, | ||
| 195 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | ||
| 196 | setWindowOpenHandler: vi.fn(), | ||
| 197 | debugger: { | ||
| 198 | isAttached: () => false, | ||
| 199 | attach: vi.fn(), | ||
| 200 | sendCommand, | ||
| 201 | on: vi.fn(), | ||
| 202 | off: vi.fn(), | ||
| 203 | }, | ||
| 204 | } as never); | ||
| 205 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | ||
| 206 | const manager = new PreviewViewManager(); | ||
| 207 | manager.onPointerEvent((event) => activity.push(event.phase)); | ||
| 208 | manager.createTab("tab_1"); | ||
| 209 | manager.registerWebview("tab_1", 42); | ||
| 210 | |||
| 211 | await manager.automationClick("tab_1", { x: 120, y: 80 }); | ||
| 212 | |||
| 213 | expect(activity).toEqual(["move", "click", "mousePressed"]); | ||
| 214 | expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { | ||
| 215 | type: "mousePressed", | ||
| 216 | x: 120, | ||
| 217 | y: 80, | ||
| 218 | button: "left", | ||
| 219 | clickCount: 1, | ||
| 220 | }); | ||
| 221 | expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { | ||
| 222 | type: "mouseReleased", | ||
| 223 | x: 120, | ||
| 224 | y: 80, | ||
| 225 | button: "left", | ||
| 226 | clickCount: 1, | ||
| 227 | }); | ||
| 228 | }); | ||
| 229 | |||
| 230 | it("still interrupts agent control for a different human pointer event", async () => { | ||
| 231 | let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; | ||
| 232 | const sendCommand = vi.fn(async (method: string) => { | ||
| 233 | if (method === "Runtime.evaluate") { | ||
| 234 | return { | ||
| 235 | result: { | ||
| 236 | value: { width: 800, height: 600 }, | ||
| 237 | }, | ||
| 238 | }; | ||
| 239 | } | ||
| 240 | if (method === "Input.dispatchMouseEvent") { | ||
| 241 | humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 }); | ||
| 242 | } | ||
| 243 | return undefined; | ||
| 244 | }); | ||
| 245 | fromId.mockReturnValue({ | ||
| 246 | id: 42, | ||
| 247 | isDestroyed: () => false, | ||
| 248 | getType: () => "webview", | ||
| 249 | getURL: () => "https://example.com", | ||
| 250 | getTitle: () => "Example", | ||
| 251 | isLoading: () => false, | ||
| 252 | isDevToolsOpened: () => false, | ||
| 253 | getZoomFactor: () => 1, | ||
| 254 | setZoomFactor: vi.fn(), | ||
| 255 | on: vi.fn(), | ||
| 256 | off: vi.fn(), | ||
| 257 | ipc: { | ||
| 258 | on: vi.fn((channel: string, listener: typeof humanInput) => { | ||
| 259 | if (channel === "preview:human-input") humanInput = listener; | ||
| 260 | }), | ||
| 261 | off: vi.fn(), | ||
| 262 | }, | ||
| 263 | send: webviewSend, | ||
| 264 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | ||
| 265 | setWindowOpenHandler: vi.fn(), | ||
| 266 | debugger: { | ||
| 267 | isAttached: () => false, | ||
| 268 | attach: vi.fn(), | ||
| 269 | sendCommand, | ||
| 270 | on: vi.fn(), | ||
| 271 | off: vi.fn(), | ||
| 272 | }, | ||
| 273 | } as never); | ||
| 274 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | ||
| 275 | const manager = new PreviewViewManager(); | ||
| 276 | manager.createTab("tab_1"); | ||
| 277 | manager.registerWebview("tab_1", 42); | ||
| 278 | |||
| 279 | await expect(manager.automationClick("tab_1", { x: 120, y: 80 })).rejects.toMatchObject({ | ||
| 280 | name: "PreviewAutomationControlInterruptedError", | ||
| 281 | }); | ||
| 282 | }); | ||
| 283 | }); | ||
apps/desktop/src/preview-view-manager.ts deleted-1741| ... | @@ -1,1741 +0,0 @@ | ||
| 1 | // @effect-diagnostics globalDate:off | ||
| 2 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 3 | /** | ||
| 4 | * PreviewViewManager — desktop side of the in-app browser preview. | ||
| 5 | * | ||
| 6 | * Hosts per-tab Chromium WebContents references (the actual <webview> | ||
| 7 | * elements live in the renderer; we only attach listeners and forward state | ||
| 8 | * here). Single layer-scoped browser session partition. | ||
| 9 | */ | ||
| 10 | import type { | ||
| 11 | DesktopPreviewAnnotationTheme, | ||
| 12 | DesktopPreviewPointerEvent, | ||
| 13 | PreviewAnnotationPayload, | ||
| 14 | PreviewAnnotationRect, | ||
| 15 | DesktopPreviewRecordingArtifact, | ||
| 16 | DesktopPreviewRecordingFrame, | ||
| 17 | DesktopPreviewScreenshotArtifact, | ||
| 18 | PreviewAutomationClickInput, | ||
| 19 | PreviewAutomationActionEvent, | ||
| 20 | PreviewAutomationConsoleEntry, | ||
| 21 | PreviewAutomationEvaluateInput, | ||
| 22 | PreviewAutomationPressInput, | ||
| 23 | PreviewAutomationNetworkEntry, | ||
| 24 | PreviewAutomationScrollInput, | ||
| 25 | PreviewAutomationSnapshot, | ||
| 26 | PreviewAutomationStatus, | ||
| 27 | PreviewAutomationTypeInput, | ||
| 28 | PreviewAutomationWaitForInput, | ||
| 29 | } from "@t3tools/contracts"; | ||
| 30 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; | ||
| 31 | import { | ||
| 32 | type BrowserWindow, | ||
| 33 | type Session, | ||
| 34 | clipboard, | ||
| 35 | nativeImage, | ||
| 36 | session, | ||
| 37 | shell, | ||
| 38 | webContents, | ||
| 39 | } from "electron"; | ||
| 40 | import { mkdir, writeFile } from "node:fs/promises"; | ||
| 41 | import { isAbsolute, join, relative, resolve, sep } from "node:path"; | ||
| 42 | import { createHash } from "node:crypto"; | ||
| 43 | import { setTimeout as sleep } from "node:timers/promises"; | ||
| 44 | |||
| 45 | import { isPreviewAnnotationPayload } from "./picked-element-payload.ts"; | ||
| 46 | import { playwrightInjectedRuntimeInstallExpression } from "./playwright-injected-runtime.ts"; | ||
| 47 | |||
| 48 | const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; | ||
| 49 | const START_PICK_CHANNEL = "preview:start-pick"; | ||
| 50 | const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; | ||
| 51 | const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; | ||
| 52 | const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; | ||
| 53 | const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; | ||
| 54 | const HUMAN_INPUT_CHANNEL = "preview:human-input"; | ||
| 55 | |||
| 56 | // Re-export the guest webview security posture from its dedicated module so | ||
| 57 | // the constant is unit-testable in isolation. See | ||
| 58 | // `preview-webview-preferences.ts` for the full security rationale. | ||
| 59 | export { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; | ||
| 60 | import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; | ||
| 61 | |||
| 62 | export type PreviewNavStatus = | ||
| 63 | | { kind: "Idle" } | ||
| 64 | | { kind: "Loading"; url: string; title: string } | ||
| 65 | | { kind: "Success"; url: string; title: string } | ||
| 66 | | { | ||
| 67 | kind: "LoadFailed"; | ||
| 68 | url: string; | ||
| 69 | title: string; | ||
| 70 | code: number; | ||
| 71 | description: string; | ||
| 72 | }; | ||
| 73 | |||
| 74 | export interface PreviewTabState { | ||
| 75 | tabId: string; | ||
| 76 | webContentsId: number | null; | ||
| 77 | navStatus: PreviewNavStatus; | ||
| 78 | canGoBack: boolean; | ||
| 79 | canGoForward: boolean; | ||
| 80 | zoomFactor: number; | ||
| 81 | controller: "human" | "agent" | "none"; | ||
| 82 | updatedAt: string; | ||
| 83 | } | ||
| 84 | |||
| 85 | /** Discrete zoom levels mirroring Chrome's preset list. */ | ||
| 86 | const ZOOM_LEVELS: ReadonlyArray<number> = [ | ||
| 87 | 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, | ||
| 88 | ]; | ||
| 89 | |||
| 90 | const DEFAULT_ZOOM_FACTOR = 1.0; | ||
| 91 | const ZOOM_EPSILON = 0.001; | ||
| 92 | const MAX_EVALUATION_BYTES = 64_000; | ||
| 93 | const MAX_VISIBLE_TEXT_LENGTH = 20_000; | ||
| 94 | const MAX_INTERACTIVE_ELEMENTS = 200; | ||
| 95 | const MAX_SCREENSHOT_WIDTH = 1280; | ||
| 96 | const DIAGNOSTIC_BUFFER_LIMIT = 200; | ||
| 97 | const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; | ||
| 98 | const AGENT_CURSOR_MOVE_MS = 160; | ||
| 99 | const AGENT_CURSOR_CLICK_LEAD_MS = 40; | ||
| 100 | const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { | ||
| 101 | colorScheme: "light", | ||
| 102 | radius: "0.625rem", | ||
| 103 | background: "white", | ||
| 104 | foreground: "oklch(0.269 0 0)", | ||
| 105 | popover: "white", | ||
| 106 | popoverForeground: "oklch(0.269 0 0)", | ||
| 107 | primary: "oklch(0.488 0.217 264)", | ||
| 108 | primaryForeground: "white", | ||
| 109 | muted: "rgb(0 0 0 / 4%)", | ||
| 110 | mutedForeground: "oklch(0.556 0 0)", | ||
| 111 | accent: "rgb(0 0 0 / 4%)", | ||
| 112 | accentForeground: "oklch(0.269 0 0)", | ||
| 113 | border: "rgb(0 0 0 / 8%)", | ||
| 114 | input: "rgb(0 0 0 / 10%)", | ||
| 115 | ring: "oklch(0.488 0.217 264)", | ||
| 116 | fontSans: "system-ui, sans-serif", | ||
| 117 | fontMono: "ui-monospace, monospace", | ||
| 118 | }; | ||
| 119 | |||
| 120 | const artifactSiteSlug = (rawUrl: string): string => { | ||
| 121 | try { | ||
| 122 | const url = new URL(rawUrl); | ||
| 123 | const slug = url.hostname | ||
| 124 | .toLowerCase() | ||
| 125 | .replace(/[^a-z0-9]+/g, "-") | ||
| 126 | .replace(/^-+|-+$/g, "") | ||
| 127 | .slice(0, MAX_ARTIFACT_SITE_SLUG_LENGTH) | ||
| 128 | .replace(/-+$/g, ""); | ||
| 129 | return slug || "site"; | ||
| 130 | } catch { | ||
| 131 | return "site"; | ||
| 132 | } | ||
| 133 | }; | ||
| 134 | |||
| 135 | interface CdpEvaluationResult { | ||
| 136 | readonly result?: { | ||
| 137 | readonly value?: unknown; | ||
| 138 | readonly description?: string; | ||
| 139 | }; | ||
| 140 | readonly exceptionDetails?: { | ||
| 141 | readonly text?: string; | ||
| 142 | readonly exception?: { readonly description?: string }; | ||
| 143 | }; | ||
| 144 | } | ||
| 145 | |||
| 146 | const automationError = ( | ||
| 147 | tag: | ||
| 148 | | "PreviewAutomationExecutionError" | ||
| 149 | | "PreviewAutomationInvalidSelectorError" | ||
| 150 | | "PreviewAutomationResultTooLargeError" | ||
| 151 | | "PreviewAutomationTimeoutError" | ||
| 152 | | "PreviewAutomationControlInterruptedError", | ||
| 153 | message: string, | ||
| 154 | detail?: unknown, | ||
| 155 | ): Error & { detail?: unknown } => { | ||
| 156 | const error = new Error(message) as Error & { detail?: unknown }; | ||
| 157 | error.name = tag; | ||
| 158 | if (detail !== undefined) error.detail = detail; | ||
| 159 | return error; | ||
| 160 | }; | ||
| 161 | |||
| 162 | const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { | ||
| 163 | if (typeof value !== "object" || value === null) return null; | ||
| 164 | const rect = value as Record<string, unknown>; | ||
| 165 | const x = rect["x"]; | ||
| 166 | const y = rect["y"]; | ||
| 167 | const width = rect["width"]; | ||
| 168 | const height = rect["height"]; | ||
| 169 | if ( | ||
| 170 | typeof x !== "number" || | ||
| 171 | !Number.isFinite(x) || | ||
| 172 | typeof y !== "number" || | ||
| 173 | !Number.isFinite(y) || | ||
| 174 | typeof width !== "number" || | ||
| 175 | !Number.isFinite(width) || | ||
| 176 | typeof height !== "number" || | ||
| 177 | !Number.isFinite(height) || | ||
| 178 | width <= 0 || | ||
| 179 | height <= 0 | ||
| 180 | ) { | ||
| 181 | return null; | ||
| 182 | } | ||
| 183 | return { | ||
| 184 | x: Math.max(0, Math.floor(x)), | ||
| 185 | y: Math.max(0, Math.floor(y)), | ||
| 186 | width: Math.max(1, Math.ceil(width)), | ||
| 187 | height: Math.max(1, Math.ceil(height)), | ||
| 188 | }; | ||
| 189 | }; | ||
| 190 | |||
| 191 | const captureAnnotationScreenshot = async ( | ||
| 192 | wc: Electron.WebContents, | ||
| 193 | cropRect: PreviewAnnotationRect | null, | ||
| 194 | ) => { | ||
| 195 | const image = await wc.capturePage( | ||
| 196 | cropRect | ||
| 197 | ? { | ||
| 198 | x: cropRect.x, | ||
| 199 | y: cropRect.y, | ||
| 200 | width: cropRect.width, | ||
| 201 | height: cropRect.height, | ||
| 202 | } | ||
| 203 | : undefined, | ||
| 204 | ); | ||
| 205 | const size = image.getSize(); | ||
| 206 | return { | ||
| 207 | dataUrl: image.toDataURL(), | ||
| 208 | width: size.width, | ||
| 209 | height: size.height, | ||
| 210 | cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height }, | ||
| 211 | }; | ||
| 212 | }; | ||
| 213 | |||
| 214 | const findZoomStep = (current: number): number => { | ||
| 215 | for (let index = 0; index < ZOOM_LEVELS.length; index += 1) { | ||
| 216 | if (Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON) return index; | ||
| 217 | if (ZOOM_LEVELS[index]! > current) return index - 1; | ||
| 218 | } | ||
| 219 | return ZOOM_LEVELS.length - 1; | ||
| 220 | }; | ||
| 221 | |||
| 222 | const nextZoomLevel = (current: number, direction: "in" | "out"): number => { | ||
| 223 | const step = findZoomStep(current); | ||
| 224 | if (direction === "in") { | ||
| 225 | return ZOOM_LEVELS[Math.min(step + 1, ZOOM_LEVELS.length - 1)] ?? current; | ||
| 226 | } | ||
| 227 | return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current; | ||
| 228 | }; | ||
| 229 | |||
| 230 | type Listener = (tabId: string, state: PreviewTabState) => void; | ||
| 231 | type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void; | ||
| 232 | |||
| 233 | type PreviewInputSignal = | ||
| 234 | | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } | ||
| 235 | | { readonly kind: "key"; readonly key: string; readonly code: string }; | ||
| 236 | |||
| 237 | interface ManagedListeners { | ||
| 238 | navigate: () => void; | ||
| 239 | failed: (event: Event, code: number, description: string) => void; | ||
| 240 | humanInput: (_event: unknown, signal?: unknown) => void; | ||
| 241 | } | ||
| 242 | |||
| 243 | interface PickSession { | ||
| 244 | readonly resolve: (payload: PreviewAnnotationPayload | null) => void; | ||
| 245 | readonly cleanup: () => void; | ||
| 246 | } | ||
| 247 | |||
| 248 | interface BrowserControlSession { | ||
| 249 | readonly webContentsId: number; | ||
| 250 | tail: Promise<void>; | ||
| 251 | initialized: Promise<void>; | ||
| 252 | } | ||
| 253 | |||
| 254 | interface BrowserDiagnostics { | ||
| 255 | readonly consoleEntries: PreviewAutomationConsoleEntry[]; | ||
| 256 | readonly networkEntries: PreviewAutomationNetworkEntry[]; | ||
| 257 | readonly requests: Map<string, { url: string; method: string }>; | ||
| 258 | } | ||
| 259 | |||
| 260 | type PointerEventListener = (event: DesktopPreviewPointerEvent) => void; | ||
| 261 | |||
| 262 | interface ExpectedAgentInput { | ||
| 263 | readonly signal: PreviewInputSignal; | ||
| 264 | readonly expiresAt: number; | ||
| 265 | } | ||
| 266 | |||
| 267 | const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ | ||
| 268 | key: string; | ||
| 269 | meta: boolean; | ||
| 270 | shift: boolean; | ||
| 271 | control: boolean; | ||
| 272 | }> = Object.freeze([ | ||
| 273 | // mod+shift+J → preview.toggle | ||
| 274 | { key: "j", meta: true, shift: true, control: false }, | ||
| 275 | // mod+K → command palette | ||
| 276 | { key: "k", meta: true, shift: false, control: false }, | ||
| 277 | // mod+, → settings (macOS convention) | ||
| 278 | { key: ",", meta: true, shift: false, control: false }, | ||
| 279 | // mod+W → close tab/panel | ||
| 280 | { key: "w", meta: true, shift: false, control: false }, | ||
| 281 | ]); | ||
| 282 | |||
| 283 | const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { | ||
| 284 | if (typeof value !== "object" || value === null || !("kind" in value)) return false; | ||
| 285 | if (value.kind === "pointer") { | ||
| 286 | return ( | ||
| 287 | "x" in value && | ||
| 288 | typeof value.x === "number" && | ||
| 289 | "y" in value && | ||
| 290 | typeof value.y === "number" && | ||
| 291 | "button" in value && | ||
| 292 | typeof value.button === "number" | ||
| 293 | ); | ||
| 294 | } | ||
| 295 | return ( | ||
| 296 | value.kind === "key" && | ||
| 297 | "key" in value && | ||
| 298 | typeof value.key === "string" && | ||
| 299 | "code" in value && | ||
| 300 | typeof value.code === "string" | ||
| 301 | ); | ||
| 302 | }; | ||
| 303 | |||
| 304 | const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): boolean => { | ||
| 305 | if (left.kind !== right.kind) return false; | ||
| 306 | if (left.kind === "pointer" && right.kind === "pointer") { | ||
| 307 | return ( | ||
| 308 | Math.abs(left.x - right.x) <= 1 && | ||
| 309 | Math.abs(left.y - right.y) <= 1 && | ||
| 310 | left.button === right.button | ||
| 311 | ); | ||
| 312 | } | ||
| 313 | return ( | ||
| 314 | left.kind === "key" && | ||
| 315 | right.kind === "key" && | ||
| 316 | left.key === right.key && | ||
| 317 | left.code === right.code | ||
| 318 | ); | ||
| 319 | }; | ||
| 320 | |||
| 321 | export class PreviewViewManager { | ||
| 322 | private annotationTheme = DEFAULT_ANNOTATION_THEME; | ||
| 323 | private artifactDirectory: string | null = null; | ||
| 324 | private mainWindow: BrowserWindow | null = null; | ||
| 325 | private readonly tabs = new Map<string, PreviewTabState>(); | ||
| 326 | private readonly attached = new Map<number, ManagedListeners>(); | ||
| 327 | private readonly browserSessions = new Map<string, Session>(); | ||
| 328 | private readonly listeners = new Set<Listener>(); | ||
| 329 | private readonly pointerEventListeners = new Set<PointerEventListener>(); | ||
| 330 | private readonly recordingFrameListeners = new Set<RecordingFrameListener>(); | ||
| 331 | /** In-flight preview annotation sessions, keyed by tabId. */ | ||
| 332 | private readonly pickSessions = new Map<string, PickSession>(); | ||
| 333 | /** One long-lived CDP attachment and serialized command queue per guest. */ | ||
| 334 | private readonly controlSessions = new Map<number, BrowserControlSession>(); | ||
| 335 | private readonly diagnostics = new Map<number, BrowserDiagnostics>(); | ||
| 336 | private readonly expectedAgentInputs = new Map<string, ExpectedAgentInput[]>(); | ||
| 337 | private readonly controlEpoch = new Map<string, number>(); | ||
| 338 | private readonly actionTimeline = new Map<string, PreviewAutomationActionEvent[]>(); | ||
| 339 | private actionSequence = 0; | ||
| 340 | private pointerSequence = 0; | ||
| 341 | private recordingTabId: string | null = null; | ||
| 342 | |||
| 343 | configureArtifactDirectory(directory: string): void { | ||
| 344 | this.artifactDirectory = resolve(directory); | ||
| 345 | } | ||
| 346 | |||
| 347 | private requireArtifactDirectory(): string { | ||
| 348 | if (!this.artifactDirectory) { | ||
| 349 | throw new Error("Preview artifact directory is not configured."); | ||
| 350 | } | ||
| 351 | return this.artifactDirectory; | ||
| 352 | } | ||
| 353 | |||
| 354 | private resolveArtifactPath(path: string): string { | ||
| 355 | const directory = this.requireArtifactDirectory(); | ||
| 356 | const resolvedPath = resolve(path); | ||
| 357 | const relativePath = relative(directory, resolvedPath); | ||
| 358 | if ( | ||
| 359 | relativePath.length === 0 || | ||
| 360 | relativePath === ".." || | ||
| 361 | relativePath.startsWith(`..${sep}`) || | ||
| 362 | isAbsolute(relativePath) | ||
| 363 | ) { | ||
| 364 | throw new Error("Preview artifact path is outside the configured artifact directory."); | ||
| 365 | } | ||
| 366 | return resolvedPath; | ||
| 367 | } | ||
| 368 | |||
| 369 | revealArtifact(path: string): void { | ||
| 370 | const resolvedPath = this.resolveArtifactPath(path); | ||
| 371 | shell.showItemInFolder(resolvedPath); | ||
| 372 | } | ||
| 373 | |||
| 374 | copyArtifactToClipboard(path: string): void { | ||
| 375 | const resolvedPath = this.resolveArtifactPath(path); | ||
| 376 | const image = nativeImage.createFromPath(resolvedPath); | ||
| 377 | if (image.isEmpty()) { | ||
| 378 | throw new Error("Preview artifact could not be loaded as an image."); | ||
| 379 | } | ||
| 380 | clipboard.writeImage(image); | ||
| 381 | } | ||
| 382 | |||
| 383 | setAnnotationTheme(theme: DesktopPreviewAnnotationTheme): void { | ||
| 384 | this.annotationTheme = theme; | ||
| 385 | for (const tab of this.tabs.values()) { | ||
| 386 | if (tab.webContentsId == null) continue; | ||
| 387 | const wc = webContents.fromId(tab.webContentsId); | ||
| 388 | if (!wc || wc.isDestroyed()) continue; | ||
| 389 | wc.send(ANNOTATION_THEME_CHANNEL, theme); | ||
| 390 | } | ||
| 391 | } | ||
| 392 | |||
| 393 | setMainWindow(window: BrowserWindow): void { | ||
| 394 | this.mainWindow = window; | ||
| 395 | } | ||
| 396 | |||
| 397 | getBrowserPartition(scope = "shared"): string { | ||
| 398 | const digest = createHash("sha256").update(scope).digest("hex").slice(0, 20); | ||
| 399 | return `${PREVIEW_PARTITION_PREFIX}${digest}`; | ||
| 400 | } | ||
| 401 | |||
| 402 | isBrowserPartition(partition: string): boolean { | ||
| 403 | return partition.startsWith(PREVIEW_PARTITION_PREFIX); | ||
| 404 | } | ||
| 405 | |||
| 406 | /** | ||
| 407 | * Returns the canonical `<webview webpreferences="...">` string. Renderer | ||
| 408 | * fetches this via the desktop bridge so the security posture for guest | ||
| 409 | * surfaces lives in exactly one place (here) and any future guest webview | ||
| 410 | * (docs panel, OAuth popup, etc.) can opt in by calling the same getter. | ||
| 411 | */ | ||
| 412 | getWebviewPreferences(): string { | ||
| 413 | return PREVIEW_WEBVIEW_PREFERENCES; | ||
| 414 | } | ||
| 415 | |||
| 416 | getBrowserSession(scope = "shared"): Session { | ||
| 417 | const partition = this.getBrowserPartition(scope); | ||
| 418 | const existing = this.browserSessions.get(partition); | ||
| 419 | if (existing) return existing; | ||
| 420 | const sess = session.fromPartition(partition); | ||
| 421 | const ua = sess | ||
| 422 | .getUserAgent() | ||
| 423 | .replace(/Electron\/[\d.]+ /, "") | ||
| 424 | .replace(/\s*t3code\/[\d.]+/, ""); | ||
| 425 | sess.setUserAgent(ua); | ||
| 426 | sess.setPermissionRequestHandler((_wc, perm, callback) => { | ||
| 427 | const allow = ["clipboard-read", "clipboard-write", "notifications", "geolocation"]; | ||
| 428 | callback(allow.includes(perm)); | ||
| 429 | }); | ||
| 430 | this.browserSessions.set(partition, sess); | ||
| 431 | return sess; | ||
| 432 | } | ||
| 433 | |||
| 434 | createTab(tabId: string): PreviewTabState { | ||
| 435 | const existing = this.tabs.get(tabId); | ||
| 436 | if (existing) return existing; | ||
| 437 | const initial: PreviewTabState = { | ||
| 438 | tabId, | ||
| 439 | webContentsId: null, | ||
| 440 | navStatus: { kind: "Idle" }, | ||
| 441 | canGoBack: false, | ||
| 442 | canGoForward: false, | ||
| 443 | zoomFactor: DEFAULT_ZOOM_FACTOR, | ||
| 444 | controller: "none", | ||
| 445 | updatedAt: new Date().toISOString(), | ||
| 446 | }; | ||
| 447 | this.tabs.set(tabId, initial); | ||
| 448 | this.emit(tabId, initial); | ||
| 449 | return initial; | ||
| 450 | } | ||
| 451 | |||
| 452 | closeTab(tabId: string): void { | ||
| 453 | const tab = this.tabs.get(tabId); | ||
| 454 | if (!tab) return; | ||
| 455 | this.cancelPickElement(tabId); | ||
| 456 | if (tab.webContentsId != null) { | ||
| 457 | this.detachControlSession(tab.webContentsId); | ||
| 458 | this.detachListeners(tab.webContentsId); | ||
| 459 | } | ||
| 460 | const closed: PreviewTabState = { | ||
| 461 | ...tab, | ||
| 462 | webContentsId: null, | ||
| 463 | navStatus: { kind: "Idle" }, | ||
| 464 | canGoBack: false, | ||
| 465 | canGoForward: false, | ||
| 466 | zoomFactor: DEFAULT_ZOOM_FACTOR, | ||
| 467 | controller: "none", | ||
| 468 | updatedAt: new Date().toISOString(), | ||
| 469 | }; | ||
| 470 | this.tabs.delete(tabId); | ||
| 471 | this.emit(tabId, closed); | ||
| 472 | } | ||
| 473 | |||
| 474 | registerWebview(tabId: string, webContentsId: number): void { | ||
| 475 | const tab = this.tabs.get(tabId); | ||
| 476 | if (!tab) { | ||
| 477 | throw new PreviewTabNotFoundError(tabId); | ||
| 478 | } | ||
| 479 | const wc = webContents.fromId(webContentsId); | ||
| 480 | if (!wc) { | ||
| 481 | throw new PreviewWebContentsNotFoundError(tabId, webContentsId); | ||
| 482 | } | ||
| 483 | // Defence in depth: a malicious renderer could otherwise trick us into | ||
| 484 | // attaching listeners to the main window's WebContents (or any other | ||
| 485 | // process) by passing an arbitrary id. | ||
| 486 | if (wc.getType() !== "webview") { | ||
| 487 | throw new PreviewWebContentsNotFoundError(tabId, webContentsId); | ||
| 488 | } | ||
| 489 | if (this.mainWindow && wc.hostWebContents !== this.mainWindow.webContents) { | ||
| 490 | throw new PreviewWebContentsNotFoundError(tabId, webContentsId); | ||
| 491 | } | ||
| 492 | if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) { | ||
| 493 | wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); | ||
| 494 | return; | ||
| 495 | } | ||
| 496 | if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { | ||
| 497 | this.detachControlSession(tab.webContentsId); | ||
| 498 | this.detachListeners(tab.webContentsId); | ||
| 499 | // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`. | ||
| 500 | // Cancel it so the toggle button doesn't get stuck pressed waiting | ||
| 501 | // forever for a click on a webview that no longer hosts the listener. | ||
| 502 | this.cancelPickElement(tabId); | ||
| 503 | } | ||
| 504 | this.attachListeners(tabId, wc); | ||
| 505 | void this.ensureControlSession(wc).catch(() => undefined); | ||
| 506 | // Restore the persisted zoom factor onto the freshly-attached WebContents | ||
| 507 | // so a thread-switch + remount lands the user back where they were. | ||
| 508 | if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { | ||
| 509 | try { | ||
| 510 | wc.setZoomFactor(tab.zoomFactor); | ||
| 511 | } catch { | ||
| 512 | // wc may have been torn down between resolution and call. | ||
| 513 | } | ||
| 514 | } | ||
| 515 | this.update(tabId, { | ||
| 516 | webContentsId, | ||
| 517 | navStatus: this.computeNavStatus(wc), | ||
| 518 | canGoBack: wc.navigationHistory.canGoBack(), | ||
| 519 | canGoForward: wc.navigationHistory.canGoForward(), | ||
| 520 | zoomFactor: tab.zoomFactor, | ||
| 521 | }); | ||
| 522 | wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); | ||
| 523 | } | ||
| 524 | |||
| 525 | async navigate(tabId: string, rawUrl: string): Promise<void> { | ||
| 526 | const wc = this.requireWebContents(tabId); | ||
| 527 | const url = this.normalizeUrl(rawUrl); | ||
| 528 | if (wc.getURL() === url) { | ||
| 529 | wc.reload(); | ||
| 530 | return; | ||
| 531 | } | ||
| 532 | await wc.loadURL(url); | ||
| 533 | } | ||
| 534 | |||
| 535 | goBack(tabId: string): void { | ||
| 536 | const wc = this.requireWebContents(tabId); | ||
| 537 | if (wc.navigationHistory.canGoBack()) { | ||
| 538 | wc.navigationHistory.goBack(); | ||
| 539 | } | ||
| 540 | } | ||
| 541 | |||
| 542 | goForward(tabId: string): void { | ||
| 543 | const wc = this.requireWebContents(tabId); | ||
| 544 | if (wc.navigationHistory.canGoForward()) { | ||
| 545 | wc.navigationHistory.goForward(); | ||
| 546 | } | ||
| 547 | } | ||
| 548 | |||
| 549 | refresh(tabId: string): void { | ||
| 550 | const wc = this.requireWebContents(tabId); | ||
| 551 | wc.reload(); | ||
| 552 | } | ||
| 553 | |||
| 554 | /** Bypass HTTP cache on the next load — equivalent to Cmd+Shift+R. */ | ||
| 555 | hardReload(tabId: string): void { | ||
| 556 | const wc = this.requireWebContents(tabId); | ||
| 557 | wc.reloadIgnoringCache(); | ||
| 558 | } | ||
| 559 | |||
| 560 | /** | ||
| 561 | * Open the guest webview's DevTools, detached so it doesn't steal panel | ||
| 562 | * area. Idempotent — re-invoking focuses the existing window. | ||
| 563 | */ | ||
| 564 | openDevTools(tabId: string): void { | ||
| 565 | const wc = this.requireWebContents(tabId); | ||
| 566 | if (wc.isDevToolsOpened()) { | ||
| 567 | wc.devToolsWebContents?.focus(); | ||
| 568 | return; | ||
| 569 | } | ||
| 570 | this.detachControlSession(wc.id); | ||
| 571 | wc.once("devtools-closed", () => { | ||
| 572 | if (!wc.isDestroyed()) void this.ensureControlSession(wc).catch(() => undefined); | ||
| 573 | }); | ||
| 574 | wc.openDevTools({ mode: "detach" }); | ||
| 575 | } | ||
| 576 | |||
| 577 | /** | ||
| 578 | * Drop cookies/localStorage/etc. for the preview partition. Affects every | ||
| 579 | * preview tab since they all share `persist:t3code-preview`. | ||
| 580 | */ | ||
| 581 | async clearCookies(): Promise<void> { | ||
| 582 | await Promise.all( | ||
| 583 | [...this.browserSessions.values()].map((sess) => | ||
| 584 | sess.clearStorageData({ | ||
| 585 | storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], | ||
| 586 | }), | ||
| 587 | ), | ||
| 588 | ); | ||
| 589 | } | ||
| 590 | |||
| 591 | /** Drop the HTTP cache for the preview partition. */ | ||
| 592 | async clearCache(): Promise<void> { | ||
| 593 | await Promise.all([...this.browserSessions.values()].map((sess) => sess.clearCache())); | ||
| 594 | } | ||
| 595 | |||
| 596 | /** | ||
| 597 | * Activate annotation mode for `tabId`. Resolves after the guest submits a | ||
| 598 | * multi-target annotation and the desktop process captures its screenshot, | ||
| 599 | * or with `null` when the user cancels. | ||
| 600 | * | ||
| 601 | * Exactly one pick session may be active per tab — re-invoking while a | ||
| 602 | * pick is in flight cleanly resolves the old session with `null` first. | ||
| 603 | */ | ||
| 604 | async pickElement(tabId: string): Promise<PreviewAnnotationPayload | null> { | ||
| 605 | const wc = this.requireWebContents(tabId); | ||
| 606 | this.cancelPickElement(tabId); | ||
| 607 | return new Promise<PreviewAnnotationPayload | null>((resolve) => { | ||
| 608 | // `wc.ipc` is the per-WebContents IpcMain that receives messages the | ||
| 609 | // webview's preload sends with `ipcRenderer.send(...)`. We use that | ||
| 610 | // (not the global `wc.on("ipc-message", ...)`, which is for | ||
| 611 | // `sendToHost` and only fires on the host renderer's <webview> | ||
| 612 | // element) so the main process actually observes the picked payload. | ||
| 613 | const cleanup = () => { | ||
| 614 | wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); | ||
| 615 | wc.off("destroyed", onDestroyed); | ||
| 616 | wc.off("did-start-navigation", onNavigated); | ||
| 617 | this.pickSessions.delete(tabId); | ||
| 618 | }; | ||
| 619 | const session: PickSession = { resolve, cleanup }; | ||
| 620 | const settle = (payload: PreviewAnnotationPayload | null) => { | ||
| 621 | if (this.pickSessions.get(tabId) !== session) return; | ||
| 622 | cleanup(); | ||
| 623 | resolve(payload); | ||
| 624 | }; | ||
| 625 | const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { | ||
| 626 | const payload = args[0]; | ||
| 627 | if (payload == null) { | ||
| 628 | settle(null); | ||
| 629 | return; | ||
| 630 | } | ||
| 631 | if (!isPreviewAnnotationPayload(payload)) { | ||
| 632 | settle(null); | ||
| 633 | return; | ||
| 634 | } | ||
| 635 | const cropRect = normalizeCaptureRect(args[1]); | ||
| 636 | void captureAnnotationScreenshot(wc, cropRect) | ||
| 637 | .then((screenshot) => settle({ ...payload, screenshot })) | ||
| 638 | .catch(() => settle(payload)) | ||
| 639 | .finally(() => { | ||
| 640 | if (wc.isDestroyed()) return; | ||
| 641 | try { | ||
| 642 | wc.send(ANNOTATION_CAPTURED_CHANNEL); | ||
| 643 | } catch { | ||
| 644 | // The guest may have navigated while capture was in flight. | ||
| 645 | } | ||
| 646 | }); | ||
| 647 | }; | ||
| 648 | const onDestroyed = () => settle(null); | ||
| 649 | const onNavigated = () => settle(null); | ||
| 650 | wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); | ||
| 651 | wc.once("destroyed", onDestroyed); | ||
| 652 | // A page navigation (incl. SPA → same-document) tears down the | ||
| 653 | // preload's listeners, so we cancel proactively to avoid hanging. | ||
| 654 | wc.once("did-start-navigation", onNavigated); | ||
| 655 | this.pickSessions.set(tabId, session); | ||
| 656 | // Force-focus the guest webContents BEFORE sending start-pick. Without | ||
| 657 | // this, Electron's input router will deliver the user's first | ||
| 658 | // mousemove/click to the host renderer (where the pick button lives) | ||
| 659 | // instead of to the guest's window listeners — manifest as "the | ||
| 660 | // picker overlay never appears on remote pages I haven't clicked | ||
| 661 | // into yet". The renderer-side handler in `PreviewView` is responsible | ||
| 662 | // for restoring focus to the previously-active host element when the | ||
| 663 | // pick promise resolves so the user's textarea cursor isn't lost. | ||
| 664 | try { | ||
| 665 | if (!wc.isFocused()) wc.focus(); | ||
| 666 | } catch { | ||
| 667 | // wc may be torn down; the next try/catch settles. | ||
| 668 | } | ||
| 669 | try { | ||
| 670 | wc.send(START_PICK_CHANNEL, this.annotationTheme); | ||
| 671 | } catch { | ||
| 672 | settle(null); | ||
| 673 | } | ||
| 674 | }); | ||
| 675 | } | ||
| 676 | |||
| 677 | cancelPickElement(tabId: string): void { | ||
| 678 | const session = this.pickSessions.get(tabId); | ||
| 679 | if (!session) return; | ||
| 680 | session.cleanup(); | ||
| 681 | // Best-effort: tell the page to dismiss the overlay even if it's still | ||
| 682 | // alive — keeps the next invoke fresh. | ||
| 683 | const tab = this.tabs.get(tabId); | ||
| 684 | if (tab?.webContentsId != null) { | ||
| 685 | const wc = webContents.fromId(tab.webContentsId); | ||
| 686 | if (wc && !wc.isDestroyed()) { | ||
| 687 | try { | ||
| 688 | wc.send(CANCEL_PICK_CHANNEL); | ||
| 689 | } catch { | ||
| 690 | // wc may have been torn down; nothing to clean up. | ||
| 691 | } | ||
| 692 | } | ||
| 693 | } | ||
| 694 | session.resolve(null); | ||
| 695 | } | ||
| 696 | |||
| 697 | async startRecording(tabId: string): Promise<void> { | ||
| 698 | if (this.recordingTabId && this.recordingTabId !== tabId) { | ||
| 699 | throw new Error("Only one browser recording can be active per window."); | ||
| 700 | } | ||
| 701 | const wc = this.requireWebContents(tabId); | ||
| 702 | await this.withControlSession(tabId, wc, "recording.start", async (send) => { | ||
| 703 | await send("Page.enable"); | ||
| 704 | await send("Page.startScreencast", { | ||
| 705 | format: "jpeg", | ||
| 706 | quality: 80, | ||
| 707 | maxWidth: 1600, | ||
| 708 | maxHeight: 1200, | ||
| 709 | everyNthFrame: 1, | ||
| 710 | }); | ||
| 711 | }); | ||
| 712 | this.recordingTabId = tabId; | ||
| 713 | } | ||
| 714 | |||
| 715 | async captureScreenshot(tabId: string): Promise<DesktopPreviewScreenshotArtifact> { | ||
| 716 | const wc = this.requireWebContents(tabId); | ||
| 717 | const createdAt = new Date().toISOString(); | ||
| 718 | const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${Date.now().toString(36)}`; | ||
| 719 | const directory = this.requireArtifactDirectory(); | ||
| 720 | const path = join(directory, `${id}.png`); | ||
| 721 | const data = (await wc.capturePage()).toPNG(); | ||
| 722 | await mkdir(directory, { recursive: true }); | ||
| 723 | await writeFile(path, data); | ||
| 724 | return { id, tabId, path, mimeType: "image/png", sizeBytes: data.byteLength, createdAt }; | ||
| 725 | } | ||
| 726 | |||
| 727 | async stopRecording(tabId: string): Promise<void> { | ||
| 728 | if (this.recordingTabId !== tabId) return; | ||
| 729 | const wc = this.requireWebContents(tabId); | ||
| 730 | await this.withControlSession(tabId, wc, "recording.stop", async (send) => { | ||
| 731 | await send("Page.stopScreencast"); | ||
| 732 | }); | ||
| 733 | this.recordingTabId = null; | ||
| 734 | } | ||
| 735 | |||
| 736 | async saveRecording( | ||
| 737 | tabId: string, | ||
| 738 | mimeType: string, | ||
| 739 | data: Uint8Array, | ||
| 740 | ): Promise<DesktopPreviewRecordingArtifact> { | ||
| 741 | const createdAt = new Date().toISOString(); | ||
| 742 | const id = `browser-recording-${Date.now().toString(36)}`; | ||
| 743 | const extension = mimeType.includes("mp4") ? "mp4" : "webm"; | ||
| 744 | const directory = this.requireArtifactDirectory(); | ||
| 745 | const path = join(directory, `${id}.${extension}`); | ||
| 746 | await mkdir(directory, { recursive: true }); | ||
| 747 | await writeFile(path, data); | ||
| 748 | return { id, tabId, path, mimeType, sizeBytes: data.byteLength, createdAt }; | ||
| 749 | } | ||
| 750 | |||
| 751 | onRecordingFrame(listener: RecordingFrameListener): () => void { | ||
| 752 | this.recordingFrameListeners.add(listener); | ||
| 753 | return () => this.recordingFrameListeners.delete(listener); | ||
| 754 | } | ||
| 755 | |||
| 756 | zoomIn(tabId: string): void { | ||
| 757 | this.applyZoom(tabId, (current) => nextZoomLevel(current, "in")); | ||
| 758 | } | ||
| 759 | |||
| 760 | zoomOut(tabId: string): void { | ||
| 761 | this.applyZoom(tabId, (current) => nextZoomLevel(current, "out")); | ||
| 762 | } | ||
| 763 | |||
| 764 | resetZoom(tabId: string): void { | ||
| 765 | this.applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR); | ||
| 766 | } | ||
| 767 | |||
| 768 | automationStatus(tabId: string): PreviewAutomationStatus { | ||
| 769 | const tab = this.tabs.get(tabId); | ||
| 770 | if (!tab || tab.webContentsId == null) { | ||
| 771 | const navStatus = tab?.navStatus; | ||
| 772 | return { | ||
| 773 | available: false, | ||
| 774 | visible: true, | ||
| 775 | tabId, | ||
| 776 | url: !navStatus || navStatus.kind === "Idle" ? null : navStatus.url, | ||
| 777 | title: !navStatus || navStatus.kind === "Idle" ? null : navStatus.title, | ||
| 778 | loading: navStatus?.kind === "Loading", | ||
| 779 | }; | ||
| 780 | } | ||
| 781 | const wc = webContents.fromId(tab.webContentsId); | ||
| 782 | if (!wc || wc.isDestroyed()) { | ||
| 783 | return { | ||
| 784 | available: false, | ||
| 785 | visible: true, | ||
| 786 | tabId, | ||
| 787 | url: null, | ||
| 788 | title: null, | ||
| 789 | loading: false, | ||
| 790 | }; | ||
| 791 | } | ||
| 792 | return { | ||
| 793 | available: true, | ||
| 794 | visible: true, | ||
| 795 | tabId, | ||
| 796 | url: wc.getURL() || null, | ||
| 797 | title: wc.getTitle() || null, | ||
| 798 | loading: wc.isLoading(), | ||
| 799 | }; | ||
| 800 | } | ||
| 801 | |||
| 802 | async automationSnapshot(tabId: string): Promise<PreviewAutomationSnapshot> { | ||
| 803 | const wc = this.requireWebContents(tabId); | ||
| 804 | return this.withControlSession(tabId, wc, "snapshot", async (send) => { | ||
| 805 | await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]); | ||
| 806 | const page = await this.evaluateWithDebugger<{ | ||
| 807 | url: string; | ||
| 808 | title: string; | ||
| 809 | loading: boolean; | ||
| 810 | visibleText: string; | ||
| 811 | interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; | ||
| 812 | }>( | ||
| 813 | send, | ||
| 814 | `(() => { | ||
| 815 | const selectorFor = (element) => { | ||
| 816 | if (element.id) return "#" + CSS.escape(element.id); | ||
| 817 | for (const attribute of ["data-testid", "name"]) { | ||
| 818 | const value = element.getAttribute(attribute); | ||
| 819 | if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; | ||
| 820 | } | ||
| 821 | const parts = []; | ||
| 822 | let current = element; | ||
| 823 | while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 8) { | ||
| 824 | let part = current.tagName.toLowerCase(); | ||
| 825 | const parent = current.parentElement; | ||
| 826 | if (parent) { | ||
| 827 | const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName); | ||
| 828 | if (siblings.length > 1) part += ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")"; | ||
| 829 | } | ||
| 830 | parts.unshift(part); | ||
| 831 | current = parent; | ||
| 832 | } | ||
| 833 | return parts.join(" > "); | ||
| 834 | }; | ||
| 835 | const visible = (element) => { | ||
| 836 | const style = getComputedStyle(element); | ||
| 837 | const rect = element.getBoundingClientRect(); | ||
| 838 | return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; | ||
| 839 | }; | ||
| 840 | const elements = Array.from(document.querySelectorAll( | ||
| 841 | "a[href],button,input,textarea,select,[role],[tabindex]" | ||
| 842 | )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { | ||
| 843 | const rect = element.getBoundingClientRect(); | ||
| 844 | return { | ||
| 845 | tag: element.tagName.toLowerCase(), | ||
| 846 | role: element.getAttribute("role"), | ||
| 847 | name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", | ||
| 848 | selector: selectorFor(element), | ||
| 849 | x: rect.x, | ||
| 850 | y: rect.y, | ||
| 851 | width: rect.width, | ||
| 852 | height: rect.height | ||
| 853 | }; | ||
| 854 | }); | ||
| 855 | return { | ||
| 856 | url: location.href, | ||
| 857 | title: document.title, | ||
| 858 | loading: document.readyState !== "complete", | ||
| 859 | visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), | ||
| 860 | interactiveElements: elements | ||
| 861 | }; | ||
| 862 | })()`, | ||
| 863 | true, | ||
| 864 | ); | ||
| 865 | const accessibility = await send("Accessibility.getFullAXTree"); | ||
| 866 | let image = await wc.capturePage(); | ||
| 867 | let size = image.getSize(); | ||
| 868 | if (size.width > MAX_SCREENSHOT_WIDTH) { | ||
| 869 | image = image.resize({ width: MAX_SCREENSHOT_WIDTH }); | ||
| 870 | size = image.getSize(); | ||
| 871 | } | ||
| 872 | return { | ||
| 873 | ...page, | ||
| 874 | accessibilityTree: accessibility, | ||
| 875 | consoleEntries: [...(this.diagnostics.get(wc.id)?.consoleEntries ?? [])], | ||
| 876 | networkEntries: [...(this.diagnostics.get(wc.id)?.networkEntries ?? [])], | ||
| 877 | actionTimeline: [...(this.actionTimeline.get(tabId) ?? [])], | ||
| 878 | screenshot: { | ||
| 879 | mimeType: "image/png", | ||
| 880 | data: image.toPNG().toString("base64"), | ||
| 881 | width: size.width, | ||
| 882 | height: size.height, | ||
| 883 | }, | ||
| 884 | }; | ||
| 885 | }); | ||
| 886 | } | ||
| 887 | |||
| 888 | async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise<void> { | ||
| 889 | const wc = this.requireWebContents(tabId); | ||
| 890 | await this.withControlSession(tabId, wc, "click", async (send) => { | ||
| 891 | await Promise.all([ | ||
| 892 | send("Runtime.enable"), | ||
| 893 | send("Input.setIgnoreInputEvents", { ignore: false }), | ||
| 894 | ]); | ||
| 895 | let x: number; | ||
| 896 | let y: number; | ||
| 897 | if ("selector" in input || "locator" in input) { | ||
| 898 | await this.ensurePlaywrightInjected(send); | ||
| 899 | const locator = this.automationLocator(input); | ||
| 900 | const point = await this.evaluateWithDebugger< | ||
| 901 | { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } | ||
| 902 | >( | ||
| 903 | send, | ||
| 904 | `(() => { | ||
| 905 | try { | ||
| 906 | const injected = globalThis.__t3PlaywrightInjected; | ||
| 907 | const parsed = injected.parseSelector(${JSON.stringify(locator)}); | ||
| 908 | const element = injected.querySelector(parsed, document, true); | ||
| 909 | if (!element) return { notFound: true }; | ||
| 910 | const visible = injected.elementState(element, "visible"); | ||
| 911 | const enabled = injected.elementState(element, "enabled"); | ||
| 912 | if (!visible.matches || !enabled.matches) return { notFound: true }; | ||
| 913 | element.scrollIntoView({ block: "center", inline: "center" }); | ||
| 914 | const rect = element.getBoundingClientRect(); | ||
| 915 | return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; | ||
| 916 | } catch (error) { | ||
| 917 | return { invalidSelector: true, message: String(error) }; | ||
| 918 | } | ||
| 919 | })()`, | ||
| 920 | true, | ||
| 921 | ); | ||
| 922 | if ("invalidSelector" in point) { | ||
| 923 | throw automationError("PreviewAutomationInvalidSelectorError", point.message, { | ||
| 924 | selector: locator, | ||
| 925 | }); | ||
| 926 | } | ||
| 927 | if ("notFound" in point) { | ||
| 928 | throw automationError( | ||
| 929 | "PreviewAutomationExecutionError", | ||
| 930 | `No element matches locator ${locator}.`, | ||
| 931 | ); | ||
| 932 | } | ||
| 933 | x = point.x; | ||
| 934 | y = point.y; | ||
| 935 | } else { | ||
| 936 | x = input.x!; | ||
| 937 | y = input.y!; | ||
| 938 | } | ||
| 939 | const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>( | ||
| 940 | send, | ||
| 941 | "({ width: window.innerWidth, height: window.innerHeight })", | ||
| 942 | true, | ||
| 943 | ); | ||
| 944 | if (x < 0 || y < 0 || x > viewport.width || y > viewport.height) { | ||
| 945 | throw automationError( | ||
| 946 | "PreviewAutomationExecutionError", | ||
| 947 | `Click coordinates (${x}, ${y}) are outside the preview viewport.`, | ||
| 948 | ); | ||
| 949 | } | ||
| 950 | this.emitPointerEvent({ | ||
| 951 | tabId, | ||
| 952 | phase: "move", | ||
| 953 | x, | ||
| 954 | y, | ||
| 955 | sequence: this.pointerSequence++, | ||
| 956 | createdAt: new Date().toISOString(), | ||
| 957 | }); | ||
| 958 | await sleep(AGENT_CURSOR_MOVE_MS); | ||
| 959 | this.emitPointerEvent({ | ||
| 960 | tabId, | ||
| 961 | phase: "click", | ||
| 962 | x, | ||
| 963 | y, | ||
| 964 | sequence: this.pointerSequence++, | ||
| 965 | createdAt: new Date().toISOString(), | ||
| 966 | }); | ||
| 967 | await sleep(AGENT_CURSOR_CLICK_LEAD_MS); | ||
| 968 | this.expectAgentInput(tabId, { kind: "pointer", x, y, button: 0 }); | ||
| 969 | await send("Input.dispatchMouseEvent", { | ||
| 970 | type: "mousePressed", | ||
| 971 | x, | ||
| 972 | y, | ||
| 973 | button: "left", | ||
| 974 | clickCount: 1, | ||
| 975 | }); | ||
| 976 | await send("Input.dispatchMouseEvent", { | ||
| 977 | type: "mouseReleased", | ||
| 978 | x, | ||
| 979 | y, | ||
| 980 | button: "left", | ||
| 981 | clickCount: 1, | ||
| 982 | }); | ||
| 983 | }); | ||
| 984 | } | ||
| 985 | |||
| 986 | async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise<void> { | ||
| 987 | const wc = this.requireWebContents(tabId); | ||
| 988 | await this.withControlSession(tabId, wc, "type", async (send) => { | ||
| 989 | await send("Runtime.enable"); | ||
| 990 | const locator = this.automationLocator(input); | ||
| 991 | if (locator) await this.ensurePlaywrightInjected(send); | ||
| 992 | const focusResult = await this.evaluateWithDebugger< | ||
| 993 | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } | ||
| 994 | >( | ||
| 995 | send, | ||
| 996 | `(() => { | ||
| 997 | try { | ||
| 998 | const element = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "document.activeElement"}; | ||
| 999 | if (!element) return { notFound: true }; | ||
| 1000 | element.focus(); | ||
| 1001 | if (${input.clear ?? false}) { | ||
| 1002 | if ("value" in element) element.value = ""; | ||
| 1003 | else if (element.isContentEditable) element.textContent = ""; | ||
| 1004 | element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" })); | ||
| 1005 | } | ||
| 1006 | return { ok: true }; | ||
| 1007 | } catch (error) { | ||
| 1008 | return { invalidSelector: true, message: String(error) }; | ||
| 1009 | } | ||
| 1010 | })()`, | ||
| 1011 | true, | ||
| 1012 | ); | ||
| 1013 | if ("invalidSelector" in focusResult) { | ||
| 1014 | throw automationError("PreviewAutomationInvalidSelectorError", focusResult.message, { | ||
| 1015 | selector: input.selector ?? "", | ||
| 1016 | }); | ||
| 1017 | } | ||
| 1018 | if ("notFound" in focusResult) { | ||
| 1019 | throw automationError( | ||
| 1020 | "PreviewAutomationExecutionError", | ||
| 1021 | locator | ||
| 1022 | ? `No element matches locator ${locator}.` | ||
| 1023 | : "No element is focused in the preview.", | ||
| 1024 | ); | ||
| 1025 | } | ||
| 1026 | await send("Input.insertText", { text: input.text }); | ||
| 1027 | await this.evaluateWithDebugger( | ||
| 1028 | send, | ||
| 1029 | `(() => { | ||
| 1030 | const element = document.activeElement; | ||
| 1031 | element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(input.text)} })); | ||
| 1032 | element?.dispatchEvent(new Event("change", { bubbles: true })); | ||
| 1033 | })()`, | ||
| 1034 | false, | ||
| 1035 | ); | ||
| 1036 | }); | ||
| 1037 | } | ||
| 1038 | |||
| 1039 | async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise<void> { | ||
| 1040 | const wc = this.requireWebContents(tabId); | ||
| 1041 | await this.withControlSession(tabId, wc, "press", async (send) => { | ||
| 1042 | const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { | ||
| 1043 | switch (modifier) { | ||
| 1044 | case "Alt": | ||
| 1045 | return value | 1; | ||
| 1046 | case "Control": | ||
| 1047 | return value | 2; | ||
| 1048 | case "Meta": | ||
| 1049 | return value | 4; | ||
| 1050 | case "Shift": | ||
| 1051 | return value | 8; | ||
| 1052 | } | ||
| 1053 | }, 0); | ||
| 1054 | const key = input.key; | ||
| 1055 | const text = key.length === 1 ? key : undefined; | ||
| 1056 | const params = { | ||
| 1057 | key, | ||
| 1058 | code: key.length === 1 ? `Key${key.toUpperCase()}` : key, | ||
| 1059 | modifiers, | ||
| 1060 | ...(text ? { text, unmodifiedText: text } : {}), | ||
| 1061 | }; | ||
| 1062 | this.expectAgentInput(tabId, { kind: "key", key, code: params.code }); | ||
| 1063 | await send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); | ||
| 1064 | await send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); | ||
| 1065 | }); | ||
| 1066 | } | ||
| 1067 | |||
| 1068 | async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise<void> { | ||
| 1069 | const wc = this.requireWebContents(tabId); | ||
| 1070 | await this.withControlSession(tabId, wc, "scroll", async (send) => { | ||
| 1071 | await send("Runtime.enable"); | ||
| 1072 | const locator = this.automationLocator(input); | ||
| 1073 | if (locator) await this.ensurePlaywrightInjected(send); | ||
| 1074 | const result = await this.evaluateWithDebugger< | ||
| 1075 | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } | ||
| 1076 | >( | ||
| 1077 | send, | ||
| 1078 | `(() => { | ||
| 1079 | try { | ||
| 1080 | const target = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "window"}; | ||
| 1081 | if (!target) return { notFound: true }; | ||
| 1082 | target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); | ||
| 1083 | return { ok: true }; | ||
| 1084 | } catch (error) { | ||
| 1085 | return { invalidSelector: true, message: String(error) }; | ||
| 1086 | } | ||
| 1087 | })()`, | ||
| 1088 | true, | ||
| 1089 | ); | ||
| 1090 | if ("invalidSelector" in result) { | ||
| 1091 | throw automationError("PreviewAutomationInvalidSelectorError", result.message, { | ||
| 1092 | selector: input.selector ?? "", | ||
| 1093 | }); | ||
| 1094 | } | ||
| 1095 | if ("notFound" in result) { | ||
| 1096 | throw automationError( | ||
| 1097 | "PreviewAutomationExecutionError", | ||
| 1098 | `No element matches locator ${locator}.`, | ||
| 1099 | ); | ||
| 1100 | } | ||
| 1101 | }); | ||
| 1102 | } | ||
| 1103 | |||
| 1104 | async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<unknown> { | ||
| 1105 | const wc = this.requireWebContents(tabId); | ||
| 1106 | return this.withControlSession(tabId, wc, "evaluate", async (send) => { | ||
| 1107 | await send("Runtime.enable"); | ||
| 1108 | const value = await this.evaluateWithDebugger( | ||
| 1109 | send, | ||
| 1110 | input.expression, | ||
| 1111 | input.returnByValue ?? true, | ||
| 1112 | input.awaitPromise ?? true, | ||
| 1113 | ); | ||
| 1114 | const serialized = JSON.stringify(value); | ||
| 1115 | if ( | ||
| 1116 | serialized !== undefined && | ||
| 1117 | Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES | ||
| 1118 | ) { | ||
| 1119 | throw automationError( | ||
| 1120 | "PreviewAutomationResultTooLargeError", | ||
| 1121 | `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, | ||
| 1122 | { maximumBytes: MAX_EVALUATION_BYTES }, | ||
| 1123 | ); | ||
| 1124 | } | ||
| 1125 | return value; | ||
| 1126 | }); | ||
| 1127 | } | ||
| 1128 | |||
| 1129 | async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<void> { | ||
| 1130 | const wc = this.requireWebContents(tabId); | ||
| 1131 | const timeoutMs = input.timeoutMs ?? 15_000; | ||
| 1132 | await this.withControlSession(tabId, wc, "waitFor", async (send) => { | ||
| 1133 | await send("Runtime.enable"); | ||
| 1134 | const locator = this.automationLocator(input); | ||
| 1135 | if (locator) await this.ensurePlaywrightInjected(send); | ||
| 1136 | const deadline = Date.now() + timeoutMs; | ||
| 1137 | while (Date.now() <= deadline) { | ||
| 1138 | const result = await this.evaluateWithDebugger< | ||
| 1139 | { matched: boolean } | { invalidSelector: true; message: string } | ||
| 1140 | >( | ||
| 1141 | send, | ||
| 1142 | `(() => { | ||
| 1143 | try { | ||
| 1144 | const selectorMatched = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, false) !== null; })()` : "true"}; | ||
| 1145 | const textMatched = ${ | ||
| 1146 | input.text | ||
| 1147 | ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})` | ||
| 1148 | : "true" | ||
| 1149 | }; | ||
| 1150 | const urlMatched = ${ | ||
| 1151 | input.urlIncludes | ||
| 1152 | ? `location.href.includes(${JSON.stringify(input.urlIncludes)})` | ||
| 1153 | : "true" | ||
| 1154 | }; | ||
| 1155 | return { matched: selectorMatched && textMatched && urlMatched }; | ||
| 1156 | } catch (error) { | ||
| 1157 | return { invalidSelector: true, message: String(error) }; | ||
| 1158 | } | ||
| 1159 | })()`, | ||
| 1160 | true, | ||
| 1161 | ); | ||
| 1162 | if ("invalidSelector" in result) { | ||
| 1163 | throw automationError("PreviewAutomationInvalidSelectorError", result.message, { | ||
| 1164 | selector: input.selector ?? "", | ||
| 1165 | }); | ||
| 1166 | } | ||
| 1167 | if (result.matched) return; | ||
| 1168 | await sleep(100); | ||
| 1169 | } | ||
| 1170 | throw automationError( | ||
| 1171 | "PreviewAutomationTimeoutError", | ||
| 1172 | `Preview condition did not match within ${timeoutMs}ms.`, | ||
| 1173 | ); | ||
| 1174 | }); | ||
| 1175 | } | ||
| 1176 | |||
| 1177 | private applyZoom(tabId: string, transform: (current: number) => number): void { | ||
| 1178 | const tab = this.tabs.get(tabId); | ||
| 1179 | if (!tab) return; | ||
| 1180 | const next = transform(tab.zoomFactor); | ||
| 1181 | if (Math.abs(next - tab.zoomFactor) < ZOOM_EPSILON) return; | ||
| 1182 | if (tab.webContentsId != null) { | ||
| 1183 | const wc = webContents.fromId(tab.webContentsId); | ||
| 1184 | if (wc && !wc.isDestroyed()) { | ||
| 1185 | wc.setZoomFactor(next); | ||
| 1186 | } | ||
| 1187 | } | ||
| 1188 | this.update(tabId, { zoomFactor: next }); | ||
| 1189 | } | ||
| 1190 | |||
| 1191 | private async withControlSession<A>( | ||
| 1192 | tabId: string, | ||
| 1193 | wc: Electron.WebContents, | ||
| 1194 | action: string, | ||
| 1195 | use: ( | ||
| 1196 | send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, | ||
| 1197 | ) => Promise<A>, | ||
| 1198 | ): Promise<A> { | ||
| 1199 | const actionEvent: PreviewAutomationActionEvent = { | ||
| 1200 | id: `browser-action-${Date.now().toString(36)}-${(this.actionSequence++).toString(36)}`, | ||
| 1201 | action, | ||
| 1202 | status: "running", | ||
| 1203 | startedAt: new Date().toISOString(), | ||
| 1204 | }; | ||
| 1205 | this.pushAction(tabId, actionEvent); | ||
| 1206 | const epoch = this.controlEpoch.get(tabId) ?? 0; | ||
| 1207 | const control = await this.ensureControlSession(wc); | ||
| 1208 | let resolveTail: () => void = () => undefined; | ||
| 1209 | const previous = control.tail; | ||
| 1210 | control.tail = new Promise<void>((resolve) => { | ||
| 1211 | resolveTail = resolve; | ||
| 1212 | }); | ||
| 1213 | await previous; | ||
| 1214 | this.update(tabId, { controller: "agent" }); | ||
| 1215 | try { | ||
| 1216 | const send = async (method: string, commandParams?: Record<string, unknown>) => { | ||
| 1217 | if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) { | ||
| 1218 | throw automationError( | ||
| 1219 | "PreviewAutomationControlInterruptedError", | ||
| 1220 | "Browser control was interrupted by human input.", | ||
| 1221 | ); | ||
| 1222 | } | ||
| 1223 | const result = await wc.debugger.sendCommand(method, commandParams); | ||
| 1224 | if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) { | ||
| 1225 | throw automationError( | ||
| 1226 | "PreviewAutomationControlInterruptedError", | ||
| 1227 | "Browser control was interrupted by human input.", | ||
| 1228 | ); | ||
| 1229 | } | ||
| 1230 | return result; | ||
| 1231 | }; | ||
| 1232 | const result = await use(send); | ||
| 1233 | this.replaceAction(tabId, { | ||
| 1234 | ...actionEvent, | ||
| 1235 | status: "succeeded", | ||
| 1236 | completedAt: new Date().toISOString(), | ||
| 1237 | }); | ||
| 1238 | return result; | ||
| 1239 | } catch (cause) { | ||
| 1240 | const interrupted = | ||
| 1241 | cause instanceof Error && cause.name === "PreviewAutomationControlInterruptedError"; | ||
| 1242 | this.replaceAction(tabId, { | ||
| 1243 | ...actionEvent, | ||
| 1244 | status: interrupted ? "interrupted" : "failed", | ||
| 1245 | completedAt: new Date().toISOString(), | ||
| 1246 | error: cause instanceof Error ? cause.message : String(cause), | ||
| 1247 | }); | ||
| 1248 | if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause; | ||
| 1249 | throw automationError( | ||
| 1250 | "PreviewAutomationExecutionError", | ||
| 1251 | cause instanceof Error ? cause.message : String(cause), | ||
| 1252 | { tabId, cause }, | ||
| 1253 | ); | ||
| 1254 | } finally { | ||
| 1255 | if (this.tabs.has(tabId)) this.update(tabId, { controller: "none" }); | ||
| 1256 | resolveTail(); | ||
| 1257 | } | ||
| 1258 | } | ||
| 1259 | |||
| 1260 | private pushAction(tabId: string, event: PreviewAutomationActionEvent): void { | ||
| 1261 | const timeline = this.actionTimeline.get(tabId) ?? []; | ||
| 1262 | timeline.push(event); | ||
| 1263 | if (timeline.length > 200) timeline.splice(0, timeline.length - 200); | ||
| 1264 | this.actionTimeline.set(tabId, timeline); | ||
| 1265 | } | ||
| 1266 | |||
| 1267 | private replaceAction(tabId: string, event: PreviewAutomationActionEvent): void { | ||
| 1268 | const timeline = this.actionTimeline.get(tabId); | ||
| 1269 | if (!timeline) return; | ||
| 1270 | const index = timeline.findIndex((candidate) => candidate.id === event.id); | ||
| 1271 | if (index >= 0) timeline[index] = event; | ||
| 1272 | } | ||
| 1273 | |||
| 1274 | private async ensureControlSession(wc: Electron.WebContents): Promise<BrowserControlSession> { | ||
| 1275 | const existing = this.controlSessions.get(wc.id); | ||
| 1276 | if (existing) { | ||
| 1277 | await existing.initialized; | ||
| 1278 | return existing; | ||
| 1279 | } | ||
| 1280 | if (wc.isDevToolsOpened()) { | ||
| 1281 | throw automationError( | ||
| 1282 | "PreviewAutomationExecutionError", | ||
| 1283 | "Close preview DevTools before using agent browser control.", | ||
| 1284 | ); | ||
| 1285 | } | ||
| 1286 | if (wc.debugger.isAttached()) { | ||
| 1287 | throw automationError( | ||
| 1288 | "PreviewAutomationExecutionError", | ||
| 1289 | "Preview control cannot attach because another debugger owns this page.", | ||
| 1290 | ); | ||
| 1291 | } | ||
| 1292 | const control: BrowserControlSession = { | ||
| 1293 | webContentsId: wc.id, | ||
| 1294 | tail: Promise.resolve(), | ||
| 1295 | initialized: Promise.resolve(), | ||
| 1296 | }; | ||
| 1297 | const diagnostics: BrowserDiagnostics = { | ||
| 1298 | consoleEntries: [], | ||
| 1299 | networkEntries: [], | ||
| 1300 | requests: new Map(), | ||
| 1301 | }; | ||
| 1302 | this.diagnostics.set(wc.id, diagnostics); | ||
| 1303 | wc.debugger.on("message", (_event, method, params) => { | ||
| 1304 | if (method === "Page.screencastFrame") { | ||
| 1305 | const frame = params as Record<string, unknown>; | ||
| 1306 | const sessionId = frame["sessionId"]; | ||
| 1307 | if (typeof sessionId === "number") { | ||
| 1308 | void wc.debugger | ||
| 1309 | .sendCommand("Page.screencastFrameAck", { sessionId }) | ||
| 1310 | .catch(() => undefined); | ||
| 1311 | } | ||
| 1312 | const tabId = this.tabIdForWebContents(wc.id); | ||
| 1313 | const metadata = | ||
| 1314 | typeof frame["metadata"] === "object" && frame["metadata"] !== null | ||
| 1315 | ? (frame["metadata"] as Record<string, unknown>) | ||
| 1316 | : {}; | ||
| 1317 | if (tabId && typeof frame["data"] === "string") { | ||
| 1318 | const payload: DesktopPreviewRecordingFrame = { | ||
| 1319 | tabId, | ||
| 1320 | data: frame["data"], | ||
| 1321 | width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, | ||
| 1322 | height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, | ||
| 1323 | receivedAt: new Date().toISOString(), | ||
| 1324 | }; | ||
| 1325 | for (const listener of this.recordingFrameListeners) listener(payload); | ||
| 1326 | } | ||
| 1327 | } | ||
| 1328 | this.captureDiagnosticMessage(diagnostics, method, params as Record<string, unknown>); | ||
| 1329 | }); | ||
| 1330 | control.initialized = (async () => { | ||
| 1331 | wc.debugger.attach("1.3"); | ||
| 1332 | await Promise.all([ | ||
| 1333 | wc.debugger.sendCommand("Runtime.enable"), | ||
| 1334 | wc.debugger.sendCommand("Accessibility.enable"), | ||
| 1335 | wc.debugger.sendCommand("Network.enable"), | ||
| 1336 | wc.debugger.sendCommand("Log.enable"), | ||
| 1337 | ]); | ||
| 1338 | })(); | ||
| 1339 | this.controlSessions.set(wc.id, control); | ||
| 1340 | try { | ||
| 1341 | await control.initialized; | ||
| 1342 | return control; | ||
| 1343 | } catch (cause) { | ||
| 1344 | this.controlSessions.delete(wc.id); | ||
| 1345 | throw cause; | ||
| 1346 | } | ||
| 1347 | } | ||
| 1348 | |||
| 1349 | private detachControlSession(webContentsId: number): void { | ||
| 1350 | this.controlSessions.delete(webContentsId); | ||
| 1351 | this.diagnostics.delete(webContentsId); | ||
| 1352 | const wc = webContents.fromId(webContentsId); | ||
| 1353 | if (!wc || wc.isDestroyed() || !wc.debugger.isAttached()) return; | ||
| 1354 | try { | ||
| 1355 | wc.debugger.detach(); | ||
| 1356 | } catch { | ||
| 1357 | // Target teardown can race detachment. | ||
| 1358 | } | ||
| 1359 | } | ||
| 1360 | |||
| 1361 | private tabIdForWebContents(webContentsId: number): string | null { | ||
| 1362 | for (const [tabId, tab] of this.tabs) { | ||
| 1363 | if (tab.webContentsId === webContentsId) return tabId; | ||
| 1364 | } | ||
| 1365 | return null; | ||
| 1366 | } | ||
| 1367 | |||
| 1368 | private captureDiagnosticMessage( | ||
| 1369 | diagnostics: BrowserDiagnostics, | ||
| 1370 | method: string, | ||
| 1371 | params: Record<string, unknown>, | ||
| 1372 | ): void { | ||
| 1373 | const timestamp = new Date().toISOString(); | ||
| 1374 | if (method === "Runtime.consoleAPICalled") { | ||
| 1375 | const args = Array.isArray(params["args"]) ? params["args"] : []; | ||
| 1376 | const text = args | ||
| 1377 | .map((arg) => { | ||
| 1378 | if (typeof arg !== "object" || arg === null) return String(arg); | ||
| 1379 | const value = arg as Record<string, unknown>; | ||
| 1380 | return String(value["value"] ?? value["description"] ?? ""); | ||
| 1381 | }) | ||
| 1382 | .join(" "); | ||
| 1383 | this.pushBounded(diagnostics.consoleEntries, { | ||
| 1384 | level: typeof params["type"] === "string" ? params["type"] : "log", | ||
| 1385 | text, | ||
| 1386 | timestamp, | ||
| 1387 | source: "console", | ||
| 1388 | }); | ||
| 1389 | return; | ||
| 1390 | } | ||
| 1391 | if (method === "Runtime.exceptionThrown") { | ||
| 1392 | const details = | ||
| 1393 | typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null | ||
| 1394 | ? (params["exceptionDetails"] as Record<string, unknown>) | ||
| 1395 | : {}; | ||
| 1396 | this.pushBounded(diagnostics.consoleEntries, { | ||
| 1397 | level: "error", | ||
| 1398 | text: String(details["text"] ?? "Uncaught exception"), | ||
| 1399 | timestamp, | ||
| 1400 | source: "exception", | ||
| 1401 | }); | ||
| 1402 | return; | ||
| 1403 | } | ||
| 1404 | if (method === "Log.entryAdded") { | ||
| 1405 | const entry = | ||
| 1406 | typeof params["entry"] === "object" && params["entry"] !== null | ||
| 1407 | ? (params["entry"] as Record<string, unknown>) | ||
| 1408 | : {}; | ||
| 1409 | this.pushBounded(diagnostics.consoleEntries, { | ||
| 1410 | level: typeof entry["level"] === "string" ? entry["level"] : "info", | ||
| 1411 | text: String(entry["text"] ?? ""), | ||
| 1412 | timestamp, | ||
| 1413 | source: typeof entry["source"] === "string" ? entry["source"] : "log", | ||
| 1414 | }); | ||
| 1415 | return; | ||
| 1416 | } | ||
| 1417 | const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null; | ||
| 1418 | if (method === "Network.requestWillBeSent" && requestId) { | ||
| 1419 | const request = | ||
| 1420 | typeof params["request"] === "object" && params["request"] !== null | ||
| 1421 | ? (params["request"] as Record<string, unknown>) | ||
| 1422 | : {}; | ||
| 1423 | diagnostics.requests.set(requestId, { | ||
| 1424 | url: String(request["url"] ?? ""), | ||
| 1425 | method: String(request["method"] ?? "GET"), | ||
| 1426 | }); | ||
| 1427 | return; | ||
| 1428 | } | ||
| 1429 | if (method === "Network.responseReceived" && requestId) { | ||
| 1430 | const request = diagnostics.requests.get(requestId); | ||
| 1431 | const response = | ||
| 1432 | typeof params["response"] === "object" && params["response"] !== null | ||
| 1433 | ? (params["response"] as Record<string, unknown>) | ||
| 1434 | : {}; | ||
| 1435 | const status = typeof response["status"] === "number" ? response["status"] : null; | ||
| 1436 | if (request && status !== null && status >= 400) { | ||
| 1437 | this.pushBounded(diagnostics.networkEntries, { | ||
| 1438 | ...request, | ||
| 1439 | status, | ||
| 1440 | failed: true, | ||
| 1441 | timestamp, | ||
| 1442 | }); | ||
| 1443 | } | ||
| 1444 | return; | ||
| 1445 | } | ||
| 1446 | if (method === "Network.loadingFailed" && requestId) { | ||
| 1447 | const request = diagnostics.requests.get(requestId); | ||
| 1448 | if (request) { | ||
| 1449 | this.pushBounded(diagnostics.networkEntries, { | ||
| 1450 | ...request, | ||
| 1451 | status: null, | ||
| 1452 | failed: true, | ||
| 1453 | errorText: String(params["errorText"] ?? "Network request failed"), | ||
| 1454 | timestamp, | ||
| 1455 | }); | ||
| 1456 | } | ||
| 1457 | diagnostics.requests.delete(requestId); | ||
| 1458 | return; | ||
| 1459 | } | ||
| 1460 | if (method === "Network.loadingFinished" && requestId) diagnostics.requests.delete(requestId); | ||
| 1461 | } | ||
| 1462 | |||
| 1463 | private pushBounded<A>(buffer: A[], entry: A): void { | ||
| 1464 | buffer.push(entry); | ||
| 1465 | if (buffer.length > DIAGNOSTIC_BUFFER_LIMIT) { | ||
| 1466 | buffer.splice(0, buffer.length - DIAGNOSTIC_BUFFER_LIMIT); | ||
| 1467 | } | ||
| 1468 | } | ||
| 1469 | |||
| 1470 | private async evaluateWithDebugger<A = unknown>( | ||
| 1471 | send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, | ||
| 1472 | expression: string, | ||
| 1473 | returnByValue: boolean, | ||
| 1474 | awaitPromise = true, | ||
| 1475 | ): Promise<A> { | ||
| 1476 | const response = (await send("Runtime.evaluate", { | ||
| 1477 | expression, | ||
| 1478 | awaitPromise, | ||
| 1479 | returnByValue, | ||
| 1480 | userGesture: true, | ||
| 1481 | })) as CdpEvaluationResult; | ||
| 1482 | if (response.exceptionDetails) { | ||
| 1483 | throw automationError( | ||
| 1484 | "PreviewAutomationExecutionError", | ||
| 1485 | response.exceptionDetails.exception?.description ?? | ||
| 1486 | response.exceptionDetails.text ?? | ||
| 1487 | "JavaScript evaluation failed.", | ||
| 1488 | ); | ||
| 1489 | } | ||
| 1490 | return response.result?.value as A; | ||
| 1491 | } | ||
| 1492 | |||
| 1493 | private automationLocator(input: { | ||
| 1494 | readonly selector?: string | undefined; | ||
| 1495 | readonly locator?: string | undefined; | ||
| 1496 | }): string | null { | ||
| 1497 | if (input.locator) return input.locator; | ||
| 1498 | if (input.selector) return `css=${input.selector}`; | ||
| 1499 | return null; | ||
| 1500 | } | ||
| 1501 | |||
| 1502 | private async ensurePlaywrightInjected( | ||
| 1503 | send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, | ||
| 1504 | ): Promise<void> { | ||
| 1505 | const installed = await this.evaluateWithDebugger<boolean>( | ||
| 1506 | send, | ||
| 1507 | "Boolean(globalThis.__t3PlaywrightInjected)", | ||
| 1508 | true, | ||
| 1509 | ); | ||
| 1510 | if (installed) return; | ||
| 1511 | const expression = await playwrightInjectedRuntimeInstallExpression(); | ||
| 1512 | await this.evaluateWithDebugger(send, expression, true); | ||
| 1513 | } | ||
| 1514 | |||
| 1515 | onStateChange(listener: Listener): () => void { | ||
| 1516 | this.listeners.add(listener); | ||
| 1517 | return () => { | ||
| 1518 | this.listeners.delete(listener); | ||
| 1519 | }; | ||
| 1520 | } | ||
| 1521 | |||
| 1522 | onPointerEvent(listener: PointerEventListener): () => void { | ||
| 1523 | this.pointerEventListeners.add(listener); | ||
| 1524 | return () => { | ||
| 1525 | this.pointerEventListeners.delete(listener); | ||
| 1526 | }; | ||
| 1527 | } | ||
| 1528 | |||
| 1529 | private emitPointerEvent(event: DesktopPreviewPointerEvent): void { | ||
| 1530 | for (const listener of this.pointerEventListeners) listener(event); | ||
| 1531 | } | ||
| 1532 | |||
| 1533 | private expectAgentInput(tabId: string, signal: PreviewInputSignal): void { | ||
| 1534 | const now = Date.now(); | ||
| 1535 | const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter( | ||
| 1536 | (expected) => expected.expiresAt > now, | ||
| 1537 | ); | ||
| 1538 | pending.push({ signal, expiresAt: now + 1_000 }); | ||
| 1539 | this.expectedAgentInputs.set(tabId, pending); | ||
| 1540 | } | ||
| 1541 | |||
| 1542 | private consumeExpectedAgentInput(tabId: string, signal: PreviewInputSignal): boolean { | ||
| 1543 | const now = Date.now(); | ||
| 1544 | const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter( | ||
| 1545 | (expected) => expected.expiresAt > now, | ||
| 1546 | ); | ||
| 1547 | const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal)); | ||
| 1548 | if (index < 0) { | ||
| 1549 | if (pending.length === 0) this.expectedAgentInputs.delete(tabId); | ||
| 1550 | else this.expectedAgentInputs.set(tabId, pending); | ||
| 1551 | return false; | ||
| 1552 | } | ||
| 1553 | pending.splice(index, 1); | ||
| 1554 | if (pending.length === 0) this.expectedAgentInputs.delete(tabId); | ||
| 1555 | else this.expectedAgentInputs.set(tabId, pending); | ||
| 1556 | return true; | ||
| 1557 | } | ||
| 1558 | |||
| 1559 | destroy(): void { | ||
| 1560 | for (const tabId of Array.from(this.tabs.keys())) { | ||
| 1561 | this.closeTab(tabId); | ||
| 1562 | } | ||
| 1563 | this.listeners.clear(); | ||
| 1564 | this.expectedAgentInputs.clear(); | ||
| 1565 | this.pointerEventListeners.clear(); | ||
| 1566 | this.recordingFrameListeners.clear(); | ||
| 1567 | } | ||
| 1568 | |||
| 1569 | private attachListeners(tabId: string, wc: Electron.WebContents): void { | ||
| 1570 | const sync = () => { | ||
| 1571 | if (wc.isDestroyed()) return; | ||
| 1572 | this.update(tabId, { | ||
| 1573 | navStatus: this.computeNavStatus(wc), | ||
| 1574 | canGoBack: wc.navigationHistory.canGoBack(), | ||
| 1575 | canGoForward: wc.navigationHistory.canGoForward(), | ||
| 1576 | }); | ||
| 1577 | }; | ||
| 1578 | const failed = (_event: Event, code: number, description: string): void => { | ||
| 1579 | // -3 = ABORTED (user navigated away mid-load); ignore. | ||
| 1580 | if (code === -3) return; | ||
| 1581 | this.update(tabId, { | ||
| 1582 | navStatus: { | ||
| 1583 | kind: "LoadFailed", | ||
| 1584 | url: wc.getURL(), | ||
| 1585 | title: wc.getTitle(), | ||
| 1586 | code, | ||
| 1587 | description, | ||
| 1588 | }, | ||
| 1589 | }); | ||
| 1590 | }; | ||
| 1591 | const humanInput = (_event: unknown, rawSignal?: unknown): void => { | ||
| 1592 | if (isPreviewInputSignal(rawSignal) && this.consumeExpectedAgentInput(tabId, rawSignal)) { | ||
| 1593 | return; | ||
| 1594 | } | ||
| 1595 | this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1); | ||
| 1596 | this.update(tabId, { controller: "human" }); | ||
| 1597 | void sleep(750).then(() => { | ||
| 1598 | if (this.tabs.get(tabId)?.controller === "human") { | ||
| 1599 | this.update(tabId, { controller: "none" }); | ||
| 1600 | } | ||
| 1601 | }); | ||
| 1602 | }; | ||
| 1603 | |||
| 1604 | wc.on("did-navigate", sync); | ||
| 1605 | wc.on("did-navigate-in-page", sync); | ||
| 1606 | wc.on("page-title-updated", sync); | ||
| 1607 | wc.on("did-start-loading", sync); | ||
| 1608 | wc.on("did-stop-loading", sync); | ||
| 1609 | wc.on("did-fail-load", failed as never); | ||
| 1610 | wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); | ||
| 1611 | |||
| 1612 | // Keep external links inside the same view (matches ami's policy). | ||
| 1613 | wc.setWindowOpenHandler(({ url }) => { | ||
| 1614 | void wc.loadURL(url); | ||
| 1615 | return { action: "deny" }; | ||
| 1616 | }); | ||
| 1617 | |||
| 1618 | // Forward app-level shortcuts to the main window so mod+shift+J etc. | ||
| 1619 | // still toggles the preview panel even when the webview has focus. | ||
| 1620 | wc.on("before-input-event", (event, input) => { | ||
| 1621 | if (this.isAppShortcut(input) && this.mainWindow && !this.mainWindow.isDestroyed()) { | ||
| 1622 | event.preventDefault(); | ||
| 1623 | this.mainWindow.webContents.sendInputEvent({ | ||
| 1624 | type: "keyDown", | ||
| 1625 | keyCode: input.key, | ||
| 1626 | modifiers: [ | ||
| 1627 | ...(input.meta ? (["meta"] as const) : []), | ||
| 1628 | ...(input.shift ? (["shift"] as const) : []), | ||
| 1629 | ...(input.control ? (["control"] as const) : []), | ||
| 1630 | ...(input.alt ? (["alt"] as const) : []), | ||
| 1631 | ], | ||
| 1632 | }); | ||
| 1633 | } | ||
| 1634 | }); | ||
| 1635 | |||
| 1636 | this.attached.set(wc.id, { navigate: sync, failed, humanInput }); | ||
| 1637 | } | ||
| 1638 | |||
| 1639 | private detachListeners(webContentsId: number): void { | ||
| 1640 | const handlers = this.attached.get(webContentsId); | ||
| 1641 | if (!handlers) return; | ||
| 1642 | this.attached.delete(webContentsId); | ||
| 1643 | const wc = webContents.fromId(webContentsId); | ||
| 1644 | if (!wc || wc.isDestroyed()) return; | ||
| 1645 | wc.off("did-navigate", handlers.navigate); | ||
| 1646 | wc.off("did-navigate-in-page", handlers.navigate); | ||
| 1647 | wc.off("page-title-updated", handlers.navigate); | ||
| 1648 | wc.off("did-start-loading", handlers.navigate); | ||
| 1649 | wc.off("did-stop-loading", handlers.navigate); | ||
| 1650 | wc.off("did-fail-load", handlers.failed as never); | ||
| 1651 | wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput); | ||
| 1652 | } | ||
| 1653 | |||
| 1654 | private isAppShortcut(input: Electron.Input): boolean { | ||
| 1655 | if (input.type !== "keyDown") return false; | ||
| 1656 | return APP_FORWARDED_SHORTCUTS.some( | ||
| 1657 | (shortcut) => | ||
| 1658 | shortcut.key.toLowerCase() === input.key.toLowerCase() && | ||
| 1659 | shortcut.meta === input.meta && | ||
| 1660 | shortcut.shift === input.shift && | ||
| 1661 | shortcut.control === input.control, | ||
| 1662 | ); | ||
| 1663 | } | ||
| 1664 | |||
| 1665 | private computeNavStatus(wc: Electron.WebContents): PreviewNavStatus { | ||
| 1666 | const url = wc.getURL(); | ||
| 1667 | const title = wc.getTitle(); | ||
| 1668 | if (url === "" || url === "about:blank") return { kind: "Idle" }; | ||
| 1669 | if (wc.isLoading()) return { kind: "Loading", url, title }; | ||
| 1670 | return { kind: "Success", url, title }; | ||
| 1671 | } | ||
| 1672 | |||
| 1673 | private requireWebContents(tabId: string): Electron.WebContents { | ||
| 1674 | const tab = this.tabs.get(tabId); | ||
| 1675 | if (!tab) throw new PreviewTabNotFoundError(tabId); | ||
| 1676 | if (tab.webContentsId == null) throw new PreviewWebviewNotInitializedError(tabId); | ||
| 1677 | const wc = webContents.fromId(tab.webContentsId); | ||
| 1678 | if (!wc) throw new PreviewWebContentsNotFoundError(tabId, tab.webContentsId); | ||
| 1679 | return wc; | ||
| 1680 | } | ||
| 1681 | |||
| 1682 | private update(tabId: string, patch: Partial<PreviewTabState>): void { | ||
| 1683 | const current = this.tabs.get(tabId); | ||
| 1684 | if (!current) return; | ||
| 1685 | const next: PreviewTabState = { | ||
| 1686 | ...current, | ||
| 1687 | ...patch, | ||
| 1688 | updatedAt: new Date().toISOString(), | ||
| 1689 | }; | ||
| 1690 | this.tabs.set(tabId, next); | ||
| 1691 | this.emit(tabId, next); | ||
| 1692 | } | ||
| 1693 | |||
| 1694 | private emit(tabId: string, state: PreviewTabState): void { | ||
| 1695 | for (const listener of this.listeners) { | ||
| 1696 | try { | ||
| 1697 | listener(tabId, state); | ||
| 1698 | } catch { | ||
| 1699 | // listener errors must not crash the manager | ||
| 1700 | } | ||
| 1701 | } | ||
| 1702 | } | ||
| 1703 | |||
| 1704 | private normalizeUrl(input: string): string { | ||
| 1705 | // Surface the shared error directly so the IPC caller (and any future | ||
| 1706 | // desktop-side telemetry) gets the `detail` field for free. Defining a | ||
| 1707 | // bespoke desktop class would just lose information. | ||
| 1708 | return normalizePreviewUrl(input); | ||
| 1709 | } | ||
| 1710 | } | ||
| 1711 | |||
| 1712 | export class PreviewTabNotFoundError extends Error { | ||
| 1713 | readonly tabId: string; | ||
| 1714 | constructor(tabId: string) { | ||
| 1715 | super(`Preview tab not found: ${tabId}`); | ||
| 1716 | this.name = "PreviewTabNotFoundError"; | ||
| 1717 | this.tabId = tabId; | ||
| 1718 | } | ||
| 1719 | } | ||
| 1720 | |||
| 1721 | export class PreviewWebContentsNotFoundError extends Error { | ||
| 1722 | readonly tabId: string; | ||
| 1723 | readonly webContentsId: number; | ||
| 1724 | constructor(tabId: string, webContentsId: number) { | ||
| 1725 | super(`WebContents ${webContentsId} not found for preview tab ${tabId}`); | ||
| 1726 | this.name = "PreviewWebContentsNotFoundError"; | ||
| 1727 | this.tabId = tabId; | ||
| 1728 | this.webContentsId = webContentsId; | ||
| 1729 | } | ||
| 1730 | } | ||
| 1731 | |||
| 1732 | export class PreviewWebviewNotInitializedError extends Error { | ||
| 1733 | readonly tabId: string; | ||
| 1734 | constructor(tabId: string) { | ||
| 1735 | super(`Preview tab "${tabId}" has no webview registered`); | ||
| 1736 | this.name = "PreviewWebviewNotInitializedError"; | ||
| 1737 | this.tabId = tabId; | ||
| 1738 | } | ||
| 1739 | } | ||
| 1740 | |||
| 1741 | export const previewViewManager = new PreviewViewManager(); | ||
apps/desktop/src/preview-webview-preferences.test.ts deleted-78| ... | @@ -1,78 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts"; | ||
| 4 | |||
| 5 | /** | ||
| 6 | * Mirrors Electron's webview attribute parser closely enough to catch the | ||
| 7 | * regressions we've already hit: | ||
| 8 | * | ||
| 9 | * - whitespace inside the comma-separated list silently drops keys (so | ||
| 10 | * `" sandbox=true"` becomes an unknown key and Electron falls back to | ||
| 11 | * defaults — re-opening the Node-leak window we closed), | ||
| 12 | * - non-`true`/`false` values (`"yes"`, `"no"`, etc.) are kept as truthy | ||
| 13 | * strings and assigned to a boolean preference, which silently flips | ||
| 14 | * `contextIsolation=no` to ENABLED (then react-grab can't see the React | ||
| 15 | * DevTools hook and componentName resolution always returns null). | ||
| 16 | * | ||
| 17 | * The actual Electron parser does roughly: | ||
| 18 | * | ||
| 19 | * for (const pair of webpreferences.split(',')) { | ||
| 20 | * const [key, value] = pair.split('='); | ||
| 21 | * prefs[key] = value; // value left as a string | ||
| 22 | * } | ||
| 23 | * | ||
| 24 | * then later coerces booleans via `Boolean(value)`. Replicating that here | ||
| 25 | * keeps the test independent of Electron internals while still failing if | ||
| 26 | * we accidentally ship `"contextIsolation=no"` again. | ||
| 27 | */ | ||
| 28 | function parseWebPreferences(input: string): Record<string, unknown> { | ||
| 29 | const out: Record<string, unknown> = {}; | ||
| 30 | for (const pair of input.split(",")) { | ||
| 31 | if (pair !== pair.trim()) { | ||
| 32 | // Electron's parser doesn't trim; surface the bug as undefined-key. | ||
| 33 | out[pair] = pair.split("=")[1]; | ||
| 34 | continue; | ||
| 35 | } | ||
| 36 | const [key, value] = pair.split("="); | ||
| 37 | if (!key) continue; | ||
| 38 | out[key] = value; | ||
| 39 | } | ||
| 40 | return out; | ||
| 41 | } | ||
| 42 | |||
| 43 | describe("PREVIEW_WEBVIEW_PREFERENCES", () => { | ||
| 44 | const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES); | ||
| 45 | |||
| 46 | it("contains exactly the three security-critical keys", () => { | ||
| 47 | expect(Object.keys(parsed).toSorted()).toEqual( | ||
| 48 | ["contextIsolation", "nodeIntegration", "sandbox"].toSorted(), | ||
| 49 | ); | ||
| 50 | }); | ||
| 51 | |||
| 52 | it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => { | ||
| 53 | // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so | ||
| 54 | // `contextIsolation="no"` would silently leave isolation ENABLED. Lock | ||
| 55 | // the values to `"true"` / `"false"` so the parser does the right thing. | ||
| 56 | for (const value of Object.values(parsed)) { | ||
| 57 | expect(value).toMatch(/^(true|false)$/); | ||
| 58 | } | ||
| 59 | }); | ||
| 60 | |||
| 61 | it("disables context isolation (so react-grab can see the page's React DevTools hook)", () => { | ||
| 62 | expect(parsed["contextIsolation"]).toBe("false"); | ||
| 63 | }); | ||
| 64 | |||
| 65 | it("keeps the renderer sandbox enabled (so the page cannot reach Node APIs)", () => { | ||
| 66 | expect(parsed["sandbox"]).toBe("true"); | ||
| 67 | }); | ||
| 68 | |||
| 69 | it("disables nodeIntegration (defense in depth — page never gets Node)", () => { | ||
| 70 | expect(parsed["nodeIntegration"]).toBe("false"); | ||
| 71 | }); | ||
| 72 | |||
| 73 | it("contains no whitespace (Electron's parser does not trim)", () => { | ||
| 74 | // Electron splits on `,` without trimming, so any whitespace would turn | ||
| 75 | // a key into an unknown one and silently drop the security flag. | ||
| 76 | expect(PREVIEW_WEBVIEW_PREFERENCES).not.toMatch(/\s/); | ||
| 77 | }); | ||
| 78 | }); | ||
apps/desktop/src/preview-webview-preferences.ts deleted-42| ... | @@ -1,42 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * webPreferences override applied to every preview `<webview>` element via | ||
| 3 | * its `webpreferences="..."` attribute. Single source of truth so all guest | ||
| 4 | * surfaces inherit the same security posture. | ||
| 5 | * | ||
| 6 | * Lives in its own electron-free module so the value is unit-testable | ||
| 7 | * without importing `preview-view-manager.ts` (which transitively imports | ||
| 8 | * `electron` and blows up under vitest). | ||
| 9 | * | ||
| 10 | * - `contextIsolation=false`: the picker preload needs to share `globalThis` | ||
| 11 | * with the page so react-grab/bippy can read the React DevTools hook | ||
| 12 | * (`__REACT_DEVTOOLS_GLOBAL_HOOK__`) and resolve component names. Without | ||
| 13 | * this every pick comes back with `componentName: null` even on dev React | ||
| 14 | * apps. | ||
| 15 | * - `sandbox=true`: keeps the OS-level renderer sandbox enabled. Critical | ||
| 16 | * when paired with `contextIsolation=false` — without sandbox, the preload | ||
| 17 | * has full Node access (`require`, `fs`, `child_process`, ...) and that | ||
| 18 | * `require` would land on the page's shared `globalThis`, giving any | ||
| 19 | * third-party page in the preview full Node + IPC access to the host. | ||
| 20 | * In sandboxed mode Electron still synthesizes the `electron` module for | ||
| 21 | * the preload's `import { ipcRenderer }` line, but no Node globals leak. | ||
| 22 | * - `nodeIntegration=false`: pinned for clarity (the page itself never gets | ||
| 23 | * Node access). | ||
| 24 | * | ||
| 25 | * Format notes (locked down by `preview-webview-preferences.test.ts`): | ||
| 26 | * - Whitespace-free. Electron's webpreferences parser splits on `,` and | ||
| 27 | * does not trim, so a leading space would turn a key into an unknown one | ||
| 28 | * and silently drop it. | ||
| 29 | * - Values are JS-boolean strings (`true`/`false`) — `yes`/`no` are not | ||
| 30 | * special-cased by the parser; `value="no"` becomes the truthy STRING | ||
| 31 | * `"no"` when assigned to a boolean webPreferences key. Most critically, | ||
| 32 | * `contextIsolation="no"` is truthy → contextIsolation stays ENABLED → | ||
| 33 | * react-grab can't see the React DevTools hook. | ||
| 34 | * | ||
| 35 | * Defense in depth: `apps/desktop/src/main.ts` also runs a | ||
| 36 | * `will-attach-webview` handler that force-sets `sandbox: true` and | ||
| 37 | * `nodeIntegration*: false` on the actual webPreferences object, gated on | ||
| 38 | * the preview partition, so even if this string is ever wrong, the | ||
| 39 | * security-critical flags can't regress on preview tabs. | ||
| 40 | */ | ||
| 41 | export const PREVIEW_WEBVIEW_PREFERENCES = | ||
| 42 | "contextIsolation=false,sandbox=true,nodeIntegration=false"; | ||
apps/desktop/src/preview/Annotation.css created+68| ... | @@ -0,0 +1,68 @@ | ||
| 1 | @import "tailwindcss"; | ||
| 2 | |||
| 3 | @theme inline { | ||
| 4 | --font-sans: var(--t3-font-sans); | ||
| 5 | --font-mono: var(--t3-font-mono); | ||
| 6 | --color-background: var(--t3-background); | ||
| 7 | --color-foreground: var(--t3-foreground); | ||
| 8 | --color-popover: var(--t3-popover); | ||
| 9 | --color-popover-foreground: var(--t3-popover-foreground); | ||
| 10 | --color-primary: var(--t3-primary); | ||
| 11 | --color-primary-foreground: var(--t3-primary-foreground); | ||
| 12 | --color-muted: var(--t3-muted); | ||
| 13 | --color-muted-foreground: var(--t3-muted-foreground); | ||
| 14 | --color-accent: var(--t3-accent); | ||
| 15 | --color-accent-foreground: var(--t3-accent-foreground); | ||
| 16 | --color-border: var(--t3-border); | ||
| 17 | --color-input: var(--t3-input); | ||
| 18 | --color-ring: var(--t3-ring); | ||
| 19 | --radius-sm: calc(var(--t3-radius) - 4px); | ||
| 20 | --radius-md: calc(var(--t3-radius) - 2px); | ||
| 21 | --radius-lg: var(--t3-radius); | ||
| 22 | --radius-xl: calc(var(--t3-radius) + 4px); | ||
| 23 | --radius-2xl: calc(var(--t3-radius) + 8px); | ||
| 24 | } | ||
| 25 | |||
| 26 | :host { | ||
| 27 | --t3-font-sans: | ||
| 28 | "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, | ||
| 29 | sans-serif; | ||
| 30 | --t3-font-mono: | ||
| 31 | "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; | ||
| 32 | --t3-radius: 0.625rem; | ||
| 33 | --t3-background: white; | ||
| 34 | --t3-foreground: oklch(0.269 0 0); | ||
| 35 | --t3-popover: white; | ||
| 36 | --t3-popover-foreground: oklch(0.269 0 0); | ||
| 37 | --t3-primary: oklch(0.488 0.217 264); | ||
| 38 | --t3-primary-foreground: white; | ||
| 39 | --t3-muted: rgb(0 0 0 / 4%); | ||
| 40 | --t3-muted-foreground: oklch(0.556 0 0); | ||
| 41 | --t3-accent: rgb(0 0 0 / 4%); | ||
| 42 | --t3-accent-foreground: oklch(0.269 0 0); | ||
| 43 | --t3-border: rgb(0 0 0 / 8%); | ||
| 44 | --t3-input: rgb(0 0 0 / 10%); | ||
| 45 | --t3-ring: oklch(0.488 0.217 264); | ||
| 46 | color: var(--t3-foreground); | ||
| 47 | font-family: var(--t3-font-sans); | ||
| 48 | } | ||
| 49 | |||
| 50 | * { | ||
| 51 | box-sizing: border-box; | ||
| 52 | border-color: var(--t3-border); | ||
| 53 | } | ||
| 54 | |||
| 55 | button, | ||
| 56 | input, | ||
| 57 | select, | ||
| 58 | textarea { | ||
| 59 | font: inherit; | ||
| 60 | } | ||
| 61 | |||
| 62 | button:focus-visible, | ||
| 63 | input:focus-visible, | ||
| 64 | select:focus-visible, | ||
| 65 | textarea:focus-visible { | ||
| 66 | outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent); | ||
| 67 | outline-offset: 1px; | ||
| 68 | } | ||
apps/desktop/src/preview/AnnotationStyles.generated.ts created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | // Generated by scripts/build-preview-annotation-css.mjs. Do not edit. | ||
| 2 | export const previewAnnotationStyles = | ||
| 3 | '/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: calc(var(--spacing) * 0);\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: calc(var(--spacing) * 0);\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: calc(var(--spacing) * 1);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: calc(var(--spacing) * 0);\n}\n.p-1 {\n padding: calc(var(--spacing) * 1);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: calc(var(--spacing) * 0);\n}\n.px-1 {\n padding-inline: calc(var(--spacing) * 1);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: calc(var(--spacing) * 1);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "<length>";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; | ||
apps/desktop/src/preview/BrowserSession.ts created+93| ... | @@ -0,0 +1,93 @@ | ||
| 1 | import type { Session } from "electron"; | ||
| 2 | import { session } from "electron"; | ||
| 3 | import { createHash } from "node:crypto"; | ||
| 4 | import * as Context from "effect/Context"; | ||
| 5 | import * as Data from "effect/Data"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | |||
| 9 | const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; | ||
| 10 | |||
| 11 | export class BrowserSessionError extends Data.TaggedError("BrowserSessionError")<{ | ||
| 12 | readonly operation: string; | ||
| 13 | readonly cause: unknown; | ||
| 14 | }> { | ||
| 15 | override get message() { | ||
| 16 | return `Desktop preview browser session operation failed: ${this.operation}`; | ||
| 17 | } | ||
| 18 | } | ||
| 19 | |||
| 20 | export interface BrowserSessionShape { | ||
| 21 | readonly getPartition: (scope?: string) => string; | ||
| 22 | readonly isPartition: (partition: string) => boolean; | ||
| 23 | readonly getSession: (scope?: string) => Effect.Effect<Session, BrowserSessionError>; | ||
| 24 | readonly clearCookies: () => Effect.Effect<void, BrowserSessionError>; | ||
| 25 | readonly clearCache: () => Effect.Effect<void, BrowserSessionError>; | ||
| 26 | } | ||
| 27 | |||
| 28 | export class BrowserSession extends Context.Service<BrowserSession, BrowserSessionShape>()( | ||
| 29 | "@t3tools/desktop/preview/BrowserSession", | ||
| 30 | ) {} | ||
| 31 | |||
| 32 | const make = Effect.fn("BrowserSession.make")(() => | ||
| 33 | Effect.sync(() => { | ||
| 34 | const sessions = new Map<string, Session>(); | ||
| 35 | const getPartition = (scope = "shared"): string => { | ||
| 36 | const digest = createHash("sha256").update(scope).digest("hex").slice(0, 20); | ||
| 37 | return `${PREVIEW_PARTITION_PREFIX}${digest}`; | ||
| 38 | }; | ||
| 39 | |||
| 40 | const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") { | ||
| 41 | const partition = getPartition(scope); | ||
| 42 | const existing = sessions.get(partition); | ||
| 43 | if (existing) return existing; | ||
| 44 | return yield* Effect.try({ | ||
| 45 | try: () => { | ||
| 46 | const browserSession = session.fromPartition(partition); | ||
| 47 | const userAgent = browserSession | ||
| 48 | .getUserAgent() | ||
| 49 | .replace(/Electron\/[\d.]+ /, "") | ||
| 50 | .replace(/\s*t3code\/[\d.]+/, ""); | ||
| 51 | browserSession.setUserAgent(userAgent); | ||
| 52 | browserSession.setPermissionRequestHandler((_webContents, permission, callback) => { | ||
| 53 | const allowed = ["clipboard-read", "clipboard-write", "notifications", "geolocation"]; | ||
| 54 | callback(allowed.includes(permission)); | ||
| 55 | }); | ||
| 56 | sessions.set(partition, browserSession); | ||
| 57 | return browserSession; | ||
| 58 | }, | ||
| 59 | catch: (cause) => new BrowserSessionError({ operation: "getSession", cause }), | ||
| 60 | }); | ||
| 61 | }); | ||
| 62 | |||
| 63 | return BrowserSession.of({ | ||
| 64 | getPartition, | ||
| 65 | isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX), | ||
| 66 | getSession, | ||
| 67 | clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () { | ||
| 68 | yield* Effect.tryPromise({ | ||
| 69 | try: () => | ||
| 70 | Promise.all( | ||
| 71 | [...sessions.values()].map((browserSession) => | ||
| 72 | browserSession.clearStorageData({ | ||
| 73 | storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], | ||
| 74 | }), | ||
| 75 | ), | ||
| 76 | ), | ||
| 77 | catch: (cause) => new BrowserSessionError({ operation: "clearCookies", cause }), | ||
| 78 | }); | ||
| 79 | }), | ||
| 80 | clearCache: Effect.fn("BrowserSession.clearCache")(function* () { | ||
| 81 | yield* Effect.tryPromise({ | ||
| 82 | try: () => | ||
| 83 | Promise.all( | ||
| 84 | [...sessions.values()].map((browserSession) => browserSession.clearCache()), | ||
| 85 | ), | ||
| 86 | catch: (cause) => new BrowserSessionError({ operation: "clearCache", cause }), | ||
| 87 | }); | ||
| 88 | }), | ||
| 89 | }); | ||
| 90 | }), | ||
| 91 | ); | ||
| 92 | |||
| 93 | export const layer = Layer.effect(BrowserSession, make()); | ||
apps/desktop/src/preview/GuestProtocol.ts created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | export const START_PICK_CHANNEL = "preview:start-pick"; | ||
| 2 | export const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; | ||
| 3 | export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; | ||
| 4 | export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; | ||
| 5 | export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; | ||
| 6 | export const HUMAN_INPUT_CHANNEL = "preview:human-input"; | ||
apps/desktop/src/preview/Manager.test.ts created+283| ... | @@ -0,0 +1,283 @@ | ||
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | |||
| 3 | const fromId = vi.fn(() => null); | ||
| 4 | const mkdir = vi.fn(async () => undefined); | ||
| 5 | const writeFile = vi.fn(async () => undefined); | ||
| 6 | const showItemInFolder = vi.fn(); | ||
| 7 | const writeImage = vi.fn(); | ||
| 8 | const createFromPath = vi.fn(() => ({ isEmpty: () => false })); | ||
| 9 | const webviewSend = vi.fn(); | ||
| 10 | |||
| 11 | vi.mock("node:fs/promises", () => ({ mkdir, writeFile })); | ||
| 12 | |||
| 13 | vi.mock("electron", () => ({ | ||
| 14 | clipboard: { | ||
| 15 | writeImage, | ||
| 16 | }, | ||
| 17 | nativeImage: { | ||
| 18 | createFromPath, | ||
| 19 | }, | ||
| 20 | session: { | ||
| 21 | fromPartition: vi.fn(), | ||
| 22 | }, | ||
| 23 | shell: { | ||
| 24 | showItemInFolder, | ||
| 25 | }, | ||
| 26 | webContents: { | ||
| 27 | fromId, | ||
| 28 | }, | ||
| 29 | })); | ||
| 30 | |||
| 31 | describe("PreviewViewManager automation status", () => { | ||
| 32 | beforeEach(() => { | ||
| 33 | fromId.mockClear(); | ||
| 34 | mkdir.mockClear(); | ||
| 35 | writeFile.mockClear(); | ||
| 36 | showItemInFolder.mockClear(); | ||
| 37 | writeImage.mockClear(); | ||
| 38 | createFromPath.mockClear(); | ||
| 39 | webviewSend.mockClear(); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("reports an unregistered webview as temporarily unavailable", async () => { | ||
| 43 | const { PreviewViewManager } = (await import("./Manager.ts")).__testing; | ||
| 44 | const manager = new PreviewViewManager(); | ||
| 45 | |||
| 46 | expect(manager.automationStatus("tab_1")).toEqual({ | ||
| 47 | available: false, | ||
| 48 | visible: true, | ||
| 49 | tabId: "tab_1", | ||
| 50 | url: null, | ||
| 51 | title: null, | ||
| 52 | loading: false, | ||
| 53 | }); | ||
| 54 | |||
| 55 | manager.createTab("tab_1"); | ||
| 56 | |||
| 57 | expect(manager.automationStatus("tab_1")).toEqual({ | ||
| 58 | available: false, | ||
| 59 | visible: true, | ||
| 60 | tabId: "tab_1", | ||
| 61 | url: null, | ||
| 62 | title: null, | ||
| 63 | loading: false, | ||
| 64 | }); | ||
| 65 | expect(fromId).not.toHaveBeenCalled(); | ||
| 66 | }); | ||
| 67 | |||
| 68 | it("captures a PNG screenshot into browser artifacts", async () => { | ||
| 69 | const png = Buffer.from("preview-png"); | ||
| 70 | const capturePage = vi.fn(async () => ({ toPNG: () => png })); | ||
| 71 | const listeners = new Map<string, (...args: never[]) => void>(); | ||
| 72 | fromId.mockReturnValue({ | ||
| 73 | id: 42, | ||
| 74 | isDestroyed: () => false, | ||
| 75 | getType: () => "webview", | ||
| 76 | getURL: () => "https://example.com:8443/path?query=value", | ||
| 77 | getTitle: () => "Example", | ||
| 78 | isLoading: () => false, | ||
| 79 | getZoomFactor: () => 1, | ||
| 80 | setZoomFactor: vi.fn(), | ||
| 81 | on: vi.fn((event: string, listener: (...args: never[]) => void) => { | ||
| 82 | listeners.set(event, listener); | ||
| 83 | }), | ||
| 84 | off: vi.fn(), | ||
| 85 | ipc: { on: vi.fn(), off: vi.fn() }, | ||
| 86 | send: webviewSend, | ||
| 87 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | ||
| 88 | setWindowOpenHandler: vi.fn(), | ||
| 89 | debugger: { | ||
| 90 | isAttached: () => false, | ||
| 91 | attach: vi.fn(), | ||
| 92 | sendCommand: vi.fn(async () => undefined), | ||
| 93 | on: vi.fn(), | ||
| 94 | off: vi.fn(), | ||
| 95 | }, | ||
| 96 | capturePage, | ||
| 97 | } as never); | ||
| 98 | const { PreviewViewManager } = (await import("./Manager.ts")).__testing; | ||
| 99 | const manager = new PreviewViewManager(); | ||
| 100 | manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); | ||
| 101 | manager.createTab("tab_1"); | ||
| 102 | manager.registerWebview("tab_1", 42); | ||
| 103 | |||
| 104 | expect(webviewSend).toHaveBeenCalledWith( | ||
| 105 | "preview:annotation-theme", | ||
| 106 | expect.objectContaining({ | ||
| 107 | colorScheme: "light", | ||
| 108 | primary: "oklch(0.488 0.217 264)", | ||
| 109 | }), | ||
| 110 | ); | ||
| 111 | |||
| 112 | const artifact = await manager.captureScreenshot("tab_1"); | ||
| 113 | |||
| 114 | expect(capturePage).toHaveBeenCalledOnce(); | ||
| 115 | expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts", { | ||
| 116 | recursive: true, | ||
| 117 | }); | ||
| 118 | expect(writeFile).toHaveBeenCalledWith(artifact.path, png); | ||
| 119 | expect(artifact).toMatchObject({ | ||
| 120 | tabId: "tab_1", | ||
| 121 | mimeType: "image/png", | ||
| 122 | sizeBytes: png.byteLength, | ||
| 123 | }); | ||
| 124 | expect(artifact.path).toMatch( | ||
| 125 | /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, | ||
| 126 | ); | ||
| 127 | }); | ||
| 128 | |||
| 129 | it("reveals only files inside the configured browser artifact directory", async () => { | ||
| 130 | const { PreviewViewManager } = (await import("./Manager.ts")).__testing; | ||
| 131 | const manager = new PreviewViewManager(); | ||
| 132 | manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); | ||
| 133 | |||
| 134 | manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"); | ||
| 135 | |||
| 136 | expect(showItemInFolder).toHaveBeenCalledWith( | ||
| 137 | "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png", | ||
| 138 | ); | ||
| 139 | expect(() => manager.revealArtifact("/tmp/t3/dev/settings.json")).toThrow( | ||
| 140 | "outside the configured artifact directory", | ||
| 141 | ); | ||
| 142 | }); | ||
| 143 | |||
| 144 | it("copies screenshot artifacts to the system clipboard", async () => { | ||
| 145 | const { PreviewViewManager } = (await import("./Manager.ts")).__testing; | ||
| 146 | const manager = new PreviewViewManager(); | ||
| 147 | manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); | ||
| 148 | const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"; | ||
| 149 | |||
| 150 | manager.copyArtifactToClipboard(artifactPath); | ||
| 151 | |||
| 152 | expect(createFromPath).toHaveBeenCalledWith(artifactPath); | ||
| 153 | expect(writeImage).toHaveBeenCalledOnce(); | ||
| 154 | expect(() => manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json")).toThrow( | ||
| 155 | "outside the configured artifact directory", | ||
| 156 | ); | ||
| 157 | }); | ||
| 158 | |||
| 159 | it("emits the resolved pointer target before dispatching an automation click", async () => { | ||
| 160 | let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; | ||
| 161 | const activity: string[] = []; | ||
| 162 | const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => { | ||
| 163 | if (method === "Runtime.evaluate") { | ||
| 164 | return { | ||
| 165 | result: { | ||
| 166 | value: { width: 800, height: 600 }, | ||
| 167 | }, | ||
| 168 | }; | ||
| 169 | } | ||
| 170 | if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { | ||
| 171 | activity.push("mousePressed"); | ||
| 172 | humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); | ||
| 173 | } | ||
| 174 | return undefined; | ||
| 175 | }); | ||
| 176 | fromId.mockReturnValue({ | ||
| 177 | id: 42, | ||
| 178 | isDestroyed: () => false, | ||
| 179 | getType: () => "webview", | ||
| 180 | getURL: () => "https://example.com", | ||
| 181 | getTitle: () => "Example", | ||
| 182 | isLoading: () => false, | ||
| 183 | isDevToolsOpened: () => false, | ||
| 184 | getZoomFactor: () => 1, | ||
| 185 | setZoomFactor: vi.fn(), | ||
| 186 | on: vi.fn(), | ||
| 187 | off: vi.fn(), | ||
| 188 | ipc: { | ||
| 189 | on: vi.fn((channel: string, listener: typeof humanInput) => { | ||
| 190 | if (channel === "preview:human-input") humanInput = listener; | ||
| 191 | }), | ||
| 192 | off: vi.fn(), | ||
| 193 | }, | ||
| 194 | send: webviewSend, | ||
| 195 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | ||
| 196 | setWindowOpenHandler: vi.fn(), | ||
| 197 | debugger: { | ||
| 198 | isAttached: () => false, | ||
| 199 | attach: vi.fn(), | ||
| 200 | sendCommand, | ||
| 201 | on: vi.fn(), | ||
| 202 | off: vi.fn(), | ||
| 203 | }, | ||
| 204 | } as never); | ||
| 205 | const { PreviewViewManager } = (await import("./Manager.ts")).__testing; | ||
| 206 | const manager = new PreviewViewManager(); | ||
| 207 | manager.onPointerEvent((event) => activity.push(event.phase)); | ||
| 208 | manager.createTab("tab_1"); | ||
| 209 | manager.registerWebview("tab_1", 42); | ||
| 210 | |||
| 211 | await manager.automationClick("tab_1", { x: 120, y: 80 }); | ||
| 212 | |||
| 213 | expect(activity).toEqual(["move", "click", "mousePressed"]); | ||
| 214 | expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { | ||
| 215 | type: "mousePressed", | ||
| 216 | x: 120, | ||
| 217 | y: 80, | ||
| 218 | button: "left", | ||
| 219 | clickCount: 1, | ||
| 220 | }); | ||
| 221 | expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { | ||
| 222 | type: "mouseReleased", | ||
| 223 | x: 120, | ||
| 224 | y: 80, | ||
| 225 | button: "left", | ||
| 226 | clickCount: 1, | ||
| 227 | }); | ||
| 228 | }); | ||
| 229 | |||
| 230 | it("still interrupts agent control for a different human pointer event", async () => { | ||
| 231 | let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; | ||
| 232 | const sendCommand = vi.fn(async (method: string) => { | ||
| 233 | if (method === "Runtime.evaluate") { | ||
| 234 | return { | ||
| 235 | result: { | ||
| 236 | value: { width: 800, height: 600 }, | ||
| 237 | }, | ||
| 238 | }; | ||
| 239 | } | ||
| 240 | if (method === "Input.dispatchMouseEvent") { | ||
| 241 | humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 }); | ||
| 242 | } | ||
| 243 | return undefined; | ||
| 244 | }); | ||
| 245 | fromId.mockReturnValue({ | ||
| 246 | id: 42, | ||
| 247 | isDestroyed: () => false, | ||
| 248 | getType: () => "webview", | ||
| 249 | getURL: () => "https://example.com", | ||
| 250 | getTitle: () => "Example", | ||
| 251 | isLoading: () => false, | ||
| 252 | isDevToolsOpened: () => false, | ||
| 253 | getZoomFactor: () => 1, | ||
| 254 | setZoomFactor: vi.fn(), | ||
| 255 | on: vi.fn(), | ||
| 256 | off: vi.fn(), | ||
| 257 | ipc: { | ||
| 258 | on: vi.fn((channel: string, listener: typeof humanInput) => { | ||
| 259 | if (channel === "preview:human-input") humanInput = listener; | ||
| 260 | }), | ||
| 261 | off: vi.fn(), | ||
| 262 | }, | ||
| 263 | send: webviewSend, | ||
| 264 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | ||
| 265 | setWindowOpenHandler: vi.fn(), | ||
| 266 | debugger: { | ||
| 267 | isAttached: () => false, | ||
| 268 | attach: vi.fn(), | ||
| 269 | sendCommand, | ||
| 270 | on: vi.fn(), | ||
| 271 | off: vi.fn(), | ||
| 272 | }, | ||
| 273 | } as never); | ||
| 274 | const { PreviewViewManager } = (await import("./Manager.ts")).__testing; | ||
| 275 | const manager = new PreviewViewManager(); | ||
| 276 | manager.createTab("tab_1"); | ||
| 277 | manager.registerWebview("tab_1", 42); | ||
| 278 | |||
| 279 | await expect(manager.automationClick("tab_1", { x: 120, y: 80 })).rejects.toMatchObject({ | ||
| 280 | name: "PreviewAutomationControlInterruptedError", | ||
| 281 | }); | ||
| 282 | }); | ||
| 283 | }); | ||
apps/desktop/src/preview/Manager.ts created+1949| ... | @@ -0,0 +1,1949 @@ | ||
| 1 | // @effect-diagnostics globalDate:off | ||
| 2 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 3 | /** | ||
| 4 | * PreviewViewManager — desktop side of the in-app browser preview. | ||
| 5 | * | ||
| 6 | * Hosts per-tab Chromium WebContents references (the actual <webview> | ||
| 7 | * elements live in the renderer; we only attach listeners and forward state | ||
| 8 | * here). Single layer-scoped browser session partition. | ||
| 9 | */ | ||
| 10 | import type { | ||
| 11 | DesktopPreviewAnnotationTheme, | ||
| 12 | DesktopPreviewPointerEvent, | ||
| 13 | PreviewAnnotationPayload, | ||
| 14 | PreviewAnnotationRect, | ||
| 15 | DesktopPreviewRecordingArtifact, | ||
| 16 | DesktopPreviewRecordingFrame, | ||
| 17 | DesktopPreviewScreenshotArtifact, | ||
| 18 | PreviewAutomationClickInput, | ||
| 19 | PreviewAutomationActionEvent, | ||
| 20 | PreviewAutomationConsoleEntry, | ||
| 21 | PreviewAutomationEvaluateInput, | ||
| 22 | PreviewAutomationPressInput, | ||
| 23 | PreviewAutomationNetworkEntry, | ||
| 24 | PreviewAutomationScrollInput, | ||
| 25 | PreviewAutomationSnapshot, | ||
| 26 | PreviewAutomationStatus, | ||
| 27 | PreviewAutomationTypeInput, | ||
| 28 | PreviewAutomationWaitForInput, | ||
| 29 | } from "@t3tools/contracts"; | ||
| 30 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; | ||
| 31 | import { | ||
| 32 | type BrowserWindow, | ||
| 33 | type Session, | ||
| 34 | clipboard, | ||
| 35 | nativeImage, | ||
| 36 | shell, | ||
| 37 | webContents, | ||
| 38 | } from "electron"; | ||
| 39 | import { mkdir, writeFile } from "node:fs/promises"; | ||
| 40 | import { isAbsolute, join, relative, resolve, sep } from "node:path"; | ||
| 41 | import { setTimeout as sleep } from "node:timers/promises"; | ||
| 42 | import * as Context from "effect/Context"; | ||
| 43 | import * as Data from "effect/Data"; | ||
| 44 | import * as Effect from "effect/Effect"; | ||
| 45 | import * as Layer from "effect/Layer"; | ||
| 46 | import type * as Scope from "effect/Scope"; | ||
| 47 | |||
| 48 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | ||
| 49 | import * as BrowserSession from "./BrowserSession.ts"; | ||
| 50 | import { | ||
| 51 | ANNOTATION_CAPTURED_CHANNEL, | ||
| 52 | ANNOTATION_THEME_CHANNEL, | ||
| 53 | CANCEL_PICK_CHANNEL, | ||
| 54 | ELEMENT_PICKED_CHANNEL, | ||
| 55 | HUMAN_INPUT_CHANNEL, | ||
| 56 | START_PICK_CHANNEL, | ||
| 57 | } from "./GuestProtocol.ts"; | ||
| 58 | import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; | ||
| 59 | import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts"; | ||
| 60 | |||
| 61 | export type PreviewNavStatus = | ||
| 62 | | { kind: "Idle" } | ||
| 63 | | { kind: "Loading"; url: string; title: string } | ||
| 64 | | { kind: "Success"; url: string; title: string } | ||
| 65 | | { | ||
| 66 | kind: "LoadFailed"; | ||
| 67 | url: string; | ||
| 68 | title: string; | ||
| 69 | code: number; | ||
| 70 | description: string; | ||
| 71 | }; | ||
| 72 | |||
| 73 | export interface PreviewTabState { | ||
| 74 | tabId: string; | ||
| 75 | webContentsId: number | null; | ||
| 76 | navStatus: PreviewNavStatus; | ||
| 77 | canGoBack: boolean; | ||
| 78 | canGoForward: boolean; | ||
| 79 | zoomFactor: number; | ||
| 80 | controller: "human" | "agent" | "none"; | ||
| 81 | updatedAt: string; | ||
| 82 | } | ||
| 83 | |||
| 84 | /** Discrete zoom levels mirroring Chrome's preset list. */ | ||
| 85 | const ZOOM_LEVELS: ReadonlyArray<number> = [ | ||
| 86 | 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, | ||
| 87 | ]; | ||
| 88 | |||
| 89 | const DEFAULT_ZOOM_FACTOR = 1.0; | ||
| 90 | const ZOOM_EPSILON = 0.001; | ||
| 91 | const MAX_EVALUATION_BYTES = 64_000; | ||
| 92 | const MAX_VISIBLE_TEXT_LENGTH = 20_000; | ||
| 93 | const MAX_INTERACTIVE_ELEMENTS = 200; | ||
| 94 | const MAX_SCREENSHOT_WIDTH = 1280; | ||
| 95 | const DIAGNOSTIC_BUFFER_LIMIT = 200; | ||
| 96 | const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; | ||
| 97 | const AGENT_CURSOR_MOVE_MS = 160; | ||
| 98 | const AGENT_CURSOR_CLICK_LEAD_MS = 40; | ||
| 99 | const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { | ||
| 100 | colorScheme: "light", | ||
| 101 | radius: "0.625rem", | ||
| 102 | background: "white", | ||
| 103 | foreground: "oklch(0.269 0 0)", | ||
| 104 | popover: "white", | ||
| 105 | popoverForeground: "oklch(0.269 0 0)", | ||
| 106 | primary: "oklch(0.488 0.217 264)", | ||
| 107 | primaryForeground: "white", | ||
| 108 | muted: "rgb(0 0 0 / 4%)", | ||
| 109 | mutedForeground: "oklch(0.556 0 0)", | ||
| 110 | accent: "rgb(0 0 0 / 4%)", | ||
| 111 | accentForeground: "oklch(0.269 0 0)", | ||
| 112 | border: "rgb(0 0 0 / 8%)", | ||
| 113 | input: "rgb(0 0 0 / 10%)", | ||
| 114 | ring: "oklch(0.488 0.217 264)", | ||
| 115 | fontSans: "system-ui, sans-serif", | ||
| 116 | fontMono: "ui-monospace, monospace", | ||
| 117 | }; | ||
| 118 | |||
| 119 | const artifactSiteSlug = (rawUrl: string): string => { | ||
| 120 | try { | ||
| 121 | const url = new URL(rawUrl); | ||
| 122 | const slug = url.hostname | ||
| 123 | .toLowerCase() | ||
| 124 | .replace(/[^a-z0-9]+/g, "-") | ||
| 125 | .replace(/^-+|-+$/g, "") | ||
| 126 | .slice(0, MAX_ARTIFACT_SITE_SLUG_LENGTH) | ||
| 127 | .replace(/-+$/g, ""); | ||
| 128 | return slug || "site"; | ||
| 129 | } catch { | ||
| 130 | return "site"; | ||
| 131 | } | ||
| 132 | }; | ||
| 133 | |||
| 134 | interface CdpEvaluationResult { | ||
| 135 | readonly result?: { | ||
| 136 | readonly value?: unknown; | ||
| 137 | readonly description?: string; | ||
| 138 | }; | ||
| 139 | readonly exceptionDetails?: { | ||
| 140 | readonly text?: string; | ||
| 141 | readonly exception?: { readonly description?: string }; | ||
| 142 | }; | ||
| 143 | } | ||
| 144 | |||
| 145 | const automationError = ( | ||
| 146 | tag: | ||
| 147 | | "PreviewAutomationExecutionError" | ||
| 148 | | "PreviewAutomationInvalidSelectorError" | ||
| 149 | | "PreviewAutomationResultTooLargeError" | ||
| 150 | | "PreviewAutomationTimeoutError" | ||
| 151 | | "PreviewAutomationControlInterruptedError", | ||
| 152 | message: string, | ||
| 153 | detail?: unknown, | ||
| 154 | ): Error & { detail?: unknown } => { | ||
| 155 | const error = new Error(message) as Error & { detail?: unknown }; | ||
| 156 | error.name = tag; | ||
| 157 | if (detail !== undefined) error.detail = detail; | ||
| 158 | return error; | ||
| 159 | }; | ||
| 160 | |||
| 161 | const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { | ||
| 162 | if (typeof value !== "object" || value === null) return null; | ||
| 163 | const rect = value as Record<string, unknown>; | ||
| 164 | const x = rect["x"]; | ||
| 165 | const y = rect["y"]; | ||
| 166 | const width = rect["width"]; | ||
| 167 | const height = rect["height"]; | ||
| 168 | if ( | ||
| 169 | typeof x !== "number" || | ||
| 170 | !Number.isFinite(x) || | ||
| 171 | typeof y !== "number" || | ||
| 172 | !Number.isFinite(y) || | ||
| 173 | typeof width !== "number" || | ||
| 174 | !Number.isFinite(width) || | ||
| 175 | typeof height !== "number" || | ||
| 176 | !Number.isFinite(height) || | ||
| 177 | width <= 0 || | ||
| 178 | height <= 0 | ||
| 179 | ) { | ||
| 180 | return null; | ||
| 181 | } | ||
| 182 | return { | ||
| 183 | x: Math.max(0, Math.floor(x)), | ||
| 184 | y: Math.max(0, Math.floor(y)), | ||
| 185 | width: Math.max(1, Math.ceil(width)), | ||
| 186 | height: Math.max(1, Math.ceil(height)), | ||
| 187 | }; | ||
| 188 | }; | ||
| 189 | |||
| 190 | const captureAnnotationScreenshot = async ( | ||
| 191 | wc: Electron.WebContents, | ||
| 192 | cropRect: PreviewAnnotationRect | null, | ||
| 193 | ) => { | ||
| 194 | const image = await wc.capturePage( | ||
| 195 | cropRect | ||
| 196 | ? { | ||
| 197 | x: cropRect.x, | ||
| 198 | y: cropRect.y, | ||
| 199 | width: cropRect.width, | ||
| 200 | height: cropRect.height, | ||
| 201 | } | ||
| 202 | : undefined, | ||
| 203 | ); | ||
| 204 | const size = image.getSize(); | ||
| 205 | return { | ||
| 206 | dataUrl: image.toDataURL(), | ||
| 207 | width: size.width, | ||
| 208 | height: size.height, | ||
| 209 | cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height }, | ||
| 210 | }; | ||
| 211 | }; | ||
| 212 | |||
| 213 | const findZoomStep = (current: number): number => { | ||
| 214 | for (let index = 0; index < ZOOM_LEVELS.length; index += 1) { | ||
| 215 | if (Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON) return index; | ||
| 216 | if (ZOOM_LEVELS[index]! > current) return index - 1; | ||
| 217 | } | ||
| 218 | return ZOOM_LEVELS.length - 1; | ||
| 219 | }; | ||
| 220 | |||
| 221 | const nextZoomLevel = (current: number, direction: "in" | "out"): number => { | ||
| 222 | const step = findZoomStep(current); | ||
| 223 | if (direction === "in") { | ||
| 224 | return ZOOM_LEVELS[Math.min(step + 1, ZOOM_LEVELS.length - 1)] ?? current; | ||
| 225 | } | ||
| 226 | return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current; | ||
| 227 | }; | ||
| 228 | |||
| 229 | type Listener = (tabId: string, state: PreviewTabState) => void; | ||
| 230 | type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void; | ||
| 231 | |||
| 232 | type PreviewInputSignal = | ||
| 233 | | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } | ||
| 234 | | { readonly kind: "key"; readonly key: string; readonly code: string }; | ||
| 235 | |||
| 236 | interface ManagedListeners { | ||
| 237 | navigate: () => void; | ||
| 238 | failed: (event: Event, code: number, description: string) => void; | ||
| 239 | humanInput: (_event: unknown, signal?: unknown) => void; | ||
| 240 | beforeInput: (event: Electron.Event, input: Electron.Input) => void; | ||
| 241 | } | ||
| 242 | |||
| 243 | interface PickSession { | ||
| 244 | readonly resolve: (payload: PreviewAnnotationPayload | null) => void; | ||
| 245 | readonly cleanup: () => void; | ||
| 246 | } | ||
| 247 | |||
| 248 | interface BrowserControlSession { | ||
| 249 | readonly webContentsId: number; | ||
| 250 | tail: Promise<void>; | ||
| 251 | initialized: Promise<void>; | ||
| 252 | readonly onMessage: ( | ||
| 253 | event: Electron.Event, | ||
| 254 | method: string, | ||
| 255 | params: Record<string, unknown>, | ||
| 256 | ) => void; | ||
| 257 | } | ||
| 258 | |||
| 259 | interface BrowserDiagnostics { | ||
| 260 | readonly consoleEntries: PreviewAutomationConsoleEntry[]; | ||
| 261 | readonly networkEntries: PreviewAutomationNetworkEntry[]; | ||
| 262 | readonly requests: Map<string, { url: string; method: string }>; | ||
| 263 | } | ||
| 264 | |||
| 265 | type PointerEventListener = (event: DesktopPreviewPointerEvent) => void; | ||
| 266 | |||
| 267 | interface ExpectedAgentInput { | ||
| 268 | readonly signal: PreviewInputSignal; | ||
| 269 | readonly expiresAt: number; | ||
| 270 | } | ||
| 271 | |||
| 272 | const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ | ||
| 273 | key: string; | ||
| 274 | meta: boolean; | ||
| 275 | shift: boolean; | ||
| 276 | control: boolean; | ||
| 277 | }> = Object.freeze([ | ||
| 278 | // mod+shift+J → preview.toggle | ||
| 279 | { key: "j", meta: true, shift: true, control: false }, | ||
| 280 | // mod+K → command palette | ||
| 281 | { key: "k", meta: true, shift: false, control: false }, | ||
| 282 | // mod+, → settings (macOS convention) | ||
| 283 | { key: ",", meta: true, shift: false, control: false }, | ||
| 284 | // mod+W → close tab/panel | ||
| 285 | { key: "w", meta: true, shift: false, control: false }, | ||
| 286 | ]); | ||
| 287 | |||
| 288 | const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { | ||
| 289 | if (typeof value !== "object" || value === null || !("kind" in value)) return false; | ||
| 290 | if (value.kind === "pointer") { | ||
| 291 | return ( | ||
| 292 | "x" in value && | ||
| 293 | typeof value.x === "number" && | ||
| 294 | "y" in value && | ||
| 295 | typeof value.y === "number" && | ||
| 296 | "button" in value && | ||
| 297 | typeof value.button === "number" | ||
| 298 | ); | ||
| 299 | } | ||
| 300 | return ( | ||
| 301 | value.kind === "key" && | ||
| 302 | "key" in value && | ||
| 303 | typeof value.key === "string" && | ||
| 304 | "code" in value && | ||
| 305 | typeof value.code === "string" | ||
| 306 | ); | ||
| 307 | }; | ||
| 308 | |||
| 309 | const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): boolean => { | ||
| 310 | if (left.kind !== right.kind) return false; | ||
| 311 | if (left.kind === "pointer" && right.kind === "pointer") { | ||
| 312 | return ( | ||
| 313 | Math.abs(left.x - right.x) <= 1 && | ||
| 314 | Math.abs(left.y - right.y) <= 1 && | ||
| 315 | left.button === right.button | ||
| 316 | ); | ||
| 317 | } | ||
| 318 | return ( | ||
| 319 | left.kind === "key" && | ||
| 320 | right.kind === "key" && | ||
| 321 | left.key === right.key && | ||
| 322 | left.code === right.code | ||
| 323 | ); | ||
| 324 | }; | ||
| 325 | |||
| 326 | class PreviewViewManager { | ||
| 327 | private annotationTheme = DEFAULT_ANNOTATION_THEME; | ||
| 328 | private artifactDirectory: string | null = null; | ||
| 329 | private mainWindow: BrowserWindow | null = null; | ||
| 330 | private readonly tabs = new Map<string, PreviewTabState>(); | ||
| 331 | private readonly attached = new Map<number, ManagedListeners>(); | ||
| 332 | private readonly listeners = new Set<Listener>(); | ||
| 333 | private readonly pointerEventListeners = new Set<PointerEventListener>(); | ||
| 334 | private readonly recordingFrameListeners = new Set<RecordingFrameListener>(); | ||
| 335 | /** In-flight preview annotation sessions, keyed by tabId. */ | ||
| 336 | private readonly pickSessions = new Map<string, PickSession>(); | ||
| 337 | /** One long-lived CDP attachment and serialized command queue per guest. */ | ||
| 338 | private readonly controlSessions = new Map<number, BrowserControlSession>(); | ||
| 339 | private readonly diagnostics = new Map<number, BrowserDiagnostics>(); | ||
| 340 | private readonly expectedAgentInputs = new Map<string, ExpectedAgentInput[]>(); | ||
| 341 | private readonly controlEpoch = new Map<string, number>(); | ||
| 342 | private readonly actionTimeline = new Map<string, PreviewAutomationActionEvent[]>(); | ||
| 343 | private actionSequence = 0; | ||
| 344 | private pointerSequence = 0; | ||
| 345 | private recordingTabId: string | null = null; | ||
| 346 | |||
| 347 | configureArtifactDirectory(directory: string): void { | ||
| 348 | this.artifactDirectory = resolve(directory); | ||
| 349 | } | ||
| 350 | |||
| 351 | private requireArtifactDirectory(): string { | ||
| 352 | if (!this.artifactDirectory) { | ||
| 353 | throw new Error("Preview artifact directory is not configured."); | ||
| 354 | } | ||
| 355 | return this.artifactDirectory; | ||
| 356 | } | ||
| 357 | |||
| 358 | private resolveArtifactPath(path: string): string { | ||
| 359 | const directory = this.requireArtifactDirectory(); | ||
| 360 | const resolvedPath = resolve(path); | ||
| 361 | const relativePath = relative(directory, resolvedPath); | ||
| 362 | if ( | ||
| 363 | relativePath.length === 0 || | ||
| 364 | relativePath === ".." || | ||
| 365 | relativePath.startsWith(`..${sep}`) || | ||
| 366 | isAbsolute(relativePath) | ||
| 367 | ) { | ||
| 368 | throw new Error("Preview artifact path is outside the configured artifact directory."); | ||
| 369 | } | ||
| 370 | return resolvedPath; | ||
| 371 | } | ||
| 372 | |||
| 373 | revealArtifact(path: string): void { | ||
| 374 | const resolvedPath = this.resolveArtifactPath(path); | ||
| 375 | shell.showItemInFolder(resolvedPath); | ||
| 376 | } | ||
| 377 | |||
| 378 | copyArtifactToClipboard(path: string): void { | ||
| 379 | const resolvedPath = this.resolveArtifactPath(path); | ||
| 380 | const image = nativeImage.createFromPath(resolvedPath); | ||
| 381 | if (image.isEmpty()) { | ||
| 382 | throw new Error("Preview artifact could not be loaded as an image."); | ||
| 383 | } | ||
| 384 | clipboard.writeImage(image); | ||
| 385 | } | ||
| 386 | |||
| 387 | setAnnotationTheme(theme: DesktopPreviewAnnotationTheme): void { | ||
| 388 | this.annotationTheme = theme; | ||
| 389 | for (const tab of this.tabs.values()) { | ||
| 390 | if (tab.webContentsId == null) continue; | ||
| 391 | const wc = webContents.fromId(tab.webContentsId); | ||
| 392 | if (!wc || wc.isDestroyed()) continue; | ||
| 393 | wc.send(ANNOTATION_THEME_CHANNEL, theme); | ||
| 394 | } | ||
| 395 | } | ||
| 396 | |||
| 397 | setMainWindow(window: BrowserWindow): void { | ||
| 398 | this.mainWindow = window; | ||
| 399 | } | ||
| 400 | |||
| 401 | createTab(tabId: string): PreviewTabState { | ||
| 402 | const existing = this.tabs.get(tabId); | ||
| 403 | if (existing) return existing; | ||
| 404 | const initial: PreviewTabState = { | ||
| 405 | tabId, | ||
| 406 | webContentsId: null, | ||
| 407 | navStatus: { kind: "Idle" }, | ||
| 408 | canGoBack: false, | ||
| 409 | canGoForward: false, | ||
| 410 | zoomFactor: DEFAULT_ZOOM_FACTOR, | ||
| 411 | controller: "none", | ||
| 412 | updatedAt: new Date().toISOString(), | ||
| 413 | }; | ||
| 414 | this.tabs.set(tabId, initial); | ||
| 415 | this.emit(tabId, initial); | ||
| 416 | return initial; | ||
| 417 | } | ||
| 418 | |||
| 419 | closeTab(tabId: string): void { | ||
| 420 | const tab = this.tabs.get(tabId); | ||
| 421 | if (!tab) return; | ||
| 422 | this.cancelPickElement(tabId); | ||
| 423 | if (tab.webContentsId != null) { | ||
| 424 | this.detachControlSession(tab.webContentsId); | ||
| 425 | this.detachListeners(tab.webContentsId); | ||
| 426 | } | ||
| 427 | const closed: PreviewTabState = { | ||
| 428 | ...tab, | ||
| 429 | webContentsId: null, | ||
| 430 | navStatus: { kind: "Idle" }, | ||
| 431 | canGoBack: false, | ||
| 432 | canGoForward: false, | ||
| 433 | zoomFactor: DEFAULT_ZOOM_FACTOR, | ||
| 434 | controller: "none", | ||
| 435 | updatedAt: new Date().toISOString(), | ||
| 436 | }; | ||
| 437 | this.tabs.delete(tabId); | ||
| 438 | this.emit(tabId, closed); | ||
| 439 | } | ||
| 440 | |||
| 441 | registerWebview(tabId: string, webContentsId: number): void { | ||
| 442 | const tab = this.tabs.get(tabId); | ||
| 443 | if (!tab) { | ||
| 444 | throw new PreviewTabNotFoundError(tabId); | ||
| 445 | } | ||
| 446 | const wc = webContents.fromId(webContentsId); | ||
| 447 | if (!wc) { | ||
| 448 | throw new PreviewWebContentsNotFoundError(tabId, webContentsId); | ||
| 449 | } | ||
| 450 | // Defence in depth: a malicious renderer could otherwise trick us into | ||
| 451 | // attaching listeners to the main window's WebContents (or any other | ||
| 452 | // process) by passing an arbitrary id. | ||
| 453 | if (wc.getType() !== "webview") { | ||
| 454 | throw new PreviewWebContentsNotFoundError(tabId, webContentsId); | ||
| 455 | } | ||
| 456 | if (this.mainWindow && wc.hostWebContents !== this.mainWindow.webContents) { | ||
| 457 | throw new PreviewWebContentsNotFoundError(tabId, webContentsId); | ||
| 458 | } | ||
| 459 | if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) { | ||
| 460 | wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); | ||
| 461 | return; | ||
| 462 | } | ||
| 463 | if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { | ||
| 464 | this.detachControlSession(tab.webContentsId); | ||
| 465 | this.detachListeners(tab.webContentsId); | ||
| 466 | // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`. | ||
| 467 | // Cancel it so the toggle button doesn't get stuck pressed waiting | ||
| 468 | // forever for a click on a webview that no longer hosts the listener. | ||
| 469 | this.cancelPickElement(tabId); | ||
| 470 | } | ||
| 471 | this.attachListeners(tabId, wc); | ||
| 472 | void this.ensureControlSession(wc).catch(() => undefined); | ||
| 473 | // Restore the persisted zoom factor onto the freshly-attached WebContents | ||
| 474 | // so a thread-switch + remount lands the user back where they were. | ||
| 475 | if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { | ||
| 476 | try { | ||
| 477 | wc.setZoomFactor(tab.zoomFactor); | ||
| 478 | } catch { | ||
| 479 | // wc may have been torn down between resolution and call. | ||
| 480 | } | ||
| 481 | } | ||
| 482 | this.update(tabId, { | ||
| 483 | webContentsId, | ||
| 484 | navStatus: this.computeNavStatus(wc), | ||
| 485 | canGoBack: wc.navigationHistory.canGoBack(), | ||
| 486 | canGoForward: wc.navigationHistory.canGoForward(), | ||
| 487 | zoomFactor: tab.zoomFactor, | ||
| 488 | }); | ||
| 489 | wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); | ||
| 490 | } | ||
| 491 | |||
| 492 | async navigate(tabId: string, rawUrl: string): Promise<void> { | ||
| 493 | const wc = this.requireWebContents(tabId); | ||
| 494 | const url = this.normalizeUrl(rawUrl); | ||
| 495 | if (wc.getURL() === url) { | ||
| 496 | wc.reload(); | ||
| 497 | return; | ||
| 498 | } | ||
| 499 | await wc.loadURL(url); | ||
| 500 | } | ||
| 501 | |||
| 502 | goBack(tabId: string): void { | ||
| 503 | const wc = this.requireWebContents(tabId); | ||
| 504 | if (wc.navigationHistory.canGoBack()) { | ||
| 505 | wc.navigationHistory.goBack(); | ||
| 506 | } | ||
| 507 | } | ||
| 508 | |||
| 509 | goForward(tabId: string): void { | ||
| 510 | const wc = this.requireWebContents(tabId); | ||
| 511 | if (wc.navigationHistory.canGoForward()) { | ||
| 512 | wc.navigationHistory.goForward(); | ||
| 513 | } | ||
| 514 | } | ||
| 515 | |||
| 516 | refresh(tabId: string): void { | ||
| 517 | const wc = this.requireWebContents(tabId); | ||
| 518 | wc.reload(); | ||
| 519 | } | ||
| 520 | |||
| 521 | /** Bypass HTTP cache on the next load — equivalent to Cmd+Shift+R. */ | ||
| 522 | hardReload(tabId: string): void { | ||
| 523 | const wc = this.requireWebContents(tabId); | ||
| 524 | wc.reloadIgnoringCache(); | ||
| 525 | } | ||
| 526 | |||
| 527 | /** | ||
| 528 | * Open the guest webview's DevTools, detached so it doesn't steal panel | ||
| 529 | * area. Idempotent — re-invoking focuses the existing window. | ||
| 530 | */ | ||
| 531 | openDevTools(tabId: string): void { | ||
| 532 | const wc = this.requireWebContents(tabId); | ||
| 533 | if (wc.isDevToolsOpened()) { | ||
| 534 | wc.devToolsWebContents?.focus(); | ||
| 535 | return; | ||
| 536 | } | ||
| 537 | this.detachControlSession(wc.id); | ||
| 538 | wc.once("devtools-closed", () => { | ||
| 539 | if (!wc.isDestroyed()) void this.ensureControlSession(wc).catch(() => undefined); | ||
| 540 | }); | ||
| 541 | wc.openDevTools({ mode: "detach" }); | ||
| 542 | } | ||
| 543 | |||
| 544 | /** | ||
| 545 | * Activate annotation mode for `tabId`. Resolves after the guest submits a | ||
| 546 | * multi-target annotation and the desktop process captures its screenshot, | ||
| 547 | * or with `null` when the user cancels. | ||
| 548 | * | ||
| 549 | * Exactly one pick session may be active per tab — re-invoking while a | ||
| 550 | * pick is in flight cleanly resolves the old session with `null` first. | ||
| 551 | */ | ||
| 552 | async pickElement(tabId: string): Promise<PreviewAnnotationPayload | null> { | ||
| 553 | const wc = this.requireWebContents(tabId); | ||
| 554 | this.cancelPickElement(tabId); | ||
| 555 | return new Promise<PreviewAnnotationPayload | null>((resolve) => { | ||
| 556 | // `wc.ipc` is the per-WebContents IpcMain that receives messages the | ||
| 557 | // webview's preload sends with `ipcRenderer.send(...)`. We use that | ||
| 558 | // (not the global `wc.on("ipc-message", ...)`, which is for | ||
| 559 | // `sendToHost` and only fires on the host renderer's <webview> | ||
| 560 | // element) so the main process actually observes the picked payload. | ||
| 561 | const cleanup = () => { | ||
| 562 | wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); | ||
| 563 | wc.off("destroyed", onDestroyed); | ||
| 564 | wc.off("did-start-navigation", onNavigated); | ||
| 565 | this.pickSessions.delete(tabId); | ||
| 566 | }; | ||
| 567 | const session: PickSession = { resolve, cleanup }; | ||
| 568 | const settle = (payload: PreviewAnnotationPayload | null) => { | ||
| 569 | if (this.pickSessions.get(tabId) !== session) return; | ||
| 570 | cleanup(); | ||
| 571 | resolve(payload); | ||
| 572 | }; | ||
| 573 | const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => { | ||
| 574 | const payload = args[0]; | ||
| 575 | if (payload == null) { | ||
| 576 | settle(null); | ||
| 577 | return; | ||
| 578 | } | ||
| 579 | if (!isPreviewAnnotationPayload(payload)) { | ||
| 580 | settle(null); | ||
| 581 | return; | ||
| 582 | } | ||
| 583 | const cropRect = normalizeCaptureRect(args[1]); | ||
| 584 | void captureAnnotationScreenshot(wc, cropRect) | ||
| 585 | .then((screenshot) => settle({ ...payload, screenshot })) | ||
| 586 | .catch(() => settle(payload)) | ||
| 587 | .finally(() => { | ||
| 588 | if (wc.isDestroyed()) return; | ||
| 589 | try { | ||
| 590 | wc.send(ANNOTATION_CAPTURED_CHANNEL); | ||
| 591 | } catch { | ||
| 592 | // The guest may have navigated while capture was in flight. | ||
| 593 | } | ||
| 594 | }); | ||
| 595 | }; | ||
| 596 | const onDestroyed = () => settle(null); | ||
| 597 | const onNavigated = () => settle(null); | ||
| 598 | wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); | ||
| 599 | wc.once("destroyed", onDestroyed); | ||
| 600 | // A page navigation (incl. SPA → same-document) tears down the | ||
| 601 | // preload's listeners, so we cancel proactively to avoid hanging. | ||
| 602 | wc.once("did-start-navigation", onNavigated); | ||
| 603 | this.pickSessions.set(tabId, session); | ||
| 604 | // Force-focus the guest webContents BEFORE sending start-pick. Without | ||
| 605 | // this, Electron's input router will deliver the user's first | ||
| 606 | // mousemove/click to the host renderer (where the pick button lives) | ||
| 607 | // instead of to the guest's window listeners — manifest as "the | ||
| 608 | // picker overlay never appears on remote pages I haven't clicked | ||
| 609 | // into yet". The renderer-side handler in `PreviewView` is responsible | ||
| 610 | // for restoring focus to the previously-active host element when the | ||
| 611 | // pick promise resolves so the user's textarea cursor isn't lost. | ||
| 612 | try { | ||
| 613 | if (!wc.isFocused()) wc.focus(); | ||
| 614 | } catch { | ||
| 615 | // wc may be torn down; the next try/catch settles. | ||
| 616 | } | ||
| 617 | try { | ||
| 618 | wc.send(START_PICK_CHANNEL, this.annotationTheme); | ||
| 619 | } catch { | ||
| 620 | settle(null); | ||
| 621 | } | ||
| 622 | }); | ||
| 623 | } | ||
| 624 | |||
| 625 | cancelPickElement(tabId: string): void { | ||
| 626 | const session = this.pickSessions.get(tabId); | ||
| 627 | if (!session) return; | ||
| 628 | session.cleanup(); | ||
| 629 | // Best-effort: tell the page to dismiss the overlay even if it's still | ||
| 630 | // alive — keeps the next invoke fresh. | ||
| 631 | const tab = this.tabs.get(tabId); | ||
| 632 | if (tab?.webContentsId != null) { | ||
| 633 | const wc = webContents.fromId(tab.webContentsId); | ||
| 634 | if (wc && !wc.isDestroyed()) { | ||
| 635 | try { | ||
| 636 | wc.send(CANCEL_PICK_CHANNEL); | ||
| 637 | } catch { | ||
| 638 | // wc may have been torn down; nothing to clean up. | ||
| 639 | } | ||
| 640 | } | ||
| 641 | } | ||
| 642 | session.resolve(null); | ||
| 643 | } | ||
| 644 | |||
| 645 | async startRecording(tabId: string): Promise<void> { | ||
| 646 | if (this.recordingTabId && this.recordingTabId !== tabId) { | ||
| 647 | throw new Error("Only one browser recording can be active per window."); | ||
| 648 | } | ||
| 649 | const wc = this.requireWebContents(tabId); | ||
| 650 | await this.withControlSession(tabId, wc, "recording.start", async (send) => { | ||
| 651 | await send("Page.enable"); | ||
| 652 | await send("Page.startScreencast", { | ||
| 653 | format: "jpeg", | ||
| 654 | quality: 80, | ||
| 655 | maxWidth: 1600, | ||
| 656 | maxHeight: 1200, | ||
| 657 | everyNthFrame: 1, | ||
| 658 | }); | ||
| 659 | }); | ||
| 660 | this.recordingTabId = tabId; | ||
| 661 | } | ||
| 662 | |||
| 663 | async captureScreenshot(tabId: string): Promise<DesktopPreviewScreenshotArtifact> { | ||
| 664 | const wc = this.requireWebContents(tabId); | ||
| 665 | const createdAt = new Date().toISOString(); | ||
| 666 | const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${Date.now().toString(36)}`; | ||
| 667 | const directory = this.requireArtifactDirectory(); | ||
| 668 | const path = join(directory, `${id}.png`); | ||
| 669 | const data = (await wc.capturePage()).toPNG(); | ||
| 670 | await mkdir(directory, { recursive: true }); | ||
| 671 | await writeFile(path, data); | ||
| 672 | return { id, tabId, path, mimeType: "image/png", sizeBytes: data.byteLength, createdAt }; | ||
| 673 | } | ||
| 674 | |||
| 675 | async stopRecording(tabId: string): Promise<void> { | ||
| 676 | if (this.recordingTabId !== tabId) return; | ||
| 677 | const wc = this.requireWebContents(tabId); | ||
| 678 | await this.withControlSession(tabId, wc, "recording.stop", async (send) => { | ||
| 679 | await send("Page.stopScreencast"); | ||
| 680 | }); | ||
| 681 | this.recordingTabId = null; | ||
| 682 | } | ||
| 683 | |||
| 684 | async saveRecording( | ||
| 685 | tabId: string, | ||
| 686 | mimeType: string, | ||
| 687 | data: Uint8Array, | ||
| 688 | ): Promise<DesktopPreviewRecordingArtifact> { | ||
| 689 | const createdAt = new Date().toISOString(); | ||
| 690 | const id = `browser-recording-${Date.now().toString(36)}`; | ||
| 691 | const extension = mimeType.includes("mp4") ? "mp4" : "webm"; | ||
| 692 | const directory = this.requireArtifactDirectory(); | ||
| 693 | const path = join(directory, `${id}.${extension}`); | ||
| 694 | await mkdir(directory, { recursive: true }); | ||
| 695 | await writeFile(path, data); | ||
| 696 | return { id, tabId, path, mimeType, sizeBytes: data.byteLength, createdAt }; | ||
| 697 | } | ||
| 698 | |||
| 699 | onRecordingFrame(listener: RecordingFrameListener): () => void { | ||
| 700 | this.recordingFrameListeners.add(listener); | ||
| 701 | return () => this.recordingFrameListeners.delete(listener); | ||
| 702 | } | ||
| 703 | |||
| 704 | zoomIn(tabId: string): void { | ||
| 705 | this.applyZoom(tabId, (current) => nextZoomLevel(current, "in")); | ||
| 706 | } | ||
| 707 | |||
| 708 | zoomOut(tabId: string): void { | ||
| 709 | this.applyZoom(tabId, (current) => nextZoomLevel(current, "out")); | ||
| 710 | } | ||
| 711 | |||
| 712 | resetZoom(tabId: string): void { | ||
| 713 | this.applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR); | ||
| 714 | } | ||
| 715 | |||
| 716 | automationStatus(tabId: string): PreviewAutomationStatus { | ||
| 717 | const tab = this.tabs.get(tabId); | ||
| 718 | if (!tab || tab.webContentsId == null) { | ||
| 719 | const navStatus = tab?.navStatus; | ||
| 720 | return { | ||
| 721 | available: false, | ||
| 722 | visible: true, | ||
| 723 | tabId, | ||
| 724 | url: !navStatus || navStatus.kind === "Idle" ? null : navStatus.url, | ||
| 725 | title: !navStatus || navStatus.kind === "Idle" ? null : navStatus.title, | ||
| 726 | loading: navStatus?.kind === "Loading", | ||
| 727 | }; | ||
| 728 | } | ||
| 729 | const wc = webContents.fromId(tab.webContentsId); | ||
| 730 | if (!wc || wc.isDestroyed()) { | ||
| 731 | return { | ||
| 732 | available: false, | ||
| 733 | visible: true, | ||
| 734 | tabId, | ||
| 735 | url: null, | ||
| 736 | title: null, | ||
| 737 | loading: false, | ||
| 738 | }; | ||
| 739 | } | ||
| 740 | return { | ||
| 741 | available: true, | ||
| 742 | visible: true, | ||
| 743 | tabId, | ||
| 744 | url: wc.getURL() || null, | ||
| 745 | title: wc.getTitle() || null, | ||
| 746 | loading: wc.isLoading(), | ||
| 747 | }; | ||
| 748 | } | ||
| 749 | |||
| 750 | async automationSnapshot(tabId: string): Promise<PreviewAutomationSnapshot> { | ||
| 751 | const wc = this.requireWebContents(tabId); | ||
| 752 | return this.withControlSession(tabId, wc, "snapshot", async (send) => { | ||
| 753 | await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]); | ||
| 754 | const page = await this.evaluateWithDebugger<{ | ||
| 755 | url: string; | ||
| 756 | title: string; | ||
| 757 | loading: boolean; | ||
| 758 | visibleText: string; | ||
| 759 | interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; | ||
| 760 | }>( | ||
| 761 | send, | ||
| 762 | `(() => { | ||
| 763 | const selectorFor = (element) => { | ||
| 764 | if (element.id) return "#" + CSS.escape(element.id); | ||
| 765 | for (const attribute of ["data-testid", "name"]) { | ||
| 766 | const value = element.getAttribute(attribute); | ||
| 767 | if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; | ||
| 768 | } | ||
| 769 | const parts = []; | ||
| 770 | let current = element; | ||
| 771 | while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 8) { | ||
| 772 | let part = current.tagName.toLowerCase(); | ||
| 773 | const parent = current.parentElement; | ||
| 774 | if (parent) { | ||
| 775 | const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName); | ||
| 776 | if (siblings.length > 1) part += ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")"; | ||
| 777 | } | ||
| 778 | parts.unshift(part); | ||
| 779 | current = parent; | ||
| 780 | } | ||
| 781 | return parts.join(" > "); | ||
| 782 | }; | ||
| 783 | const visible = (element) => { | ||
| 784 | const style = getComputedStyle(element); | ||
| 785 | const rect = element.getBoundingClientRect(); | ||
| 786 | return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; | ||
| 787 | }; | ||
| 788 | const elements = Array.from(document.querySelectorAll( | ||
| 789 | "a[href],button,input,textarea,select,[role],[tabindex]" | ||
| 790 | )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { | ||
| 791 | const rect = element.getBoundingClientRect(); | ||
| 792 | return { | ||
| 793 | tag: element.tagName.toLowerCase(), | ||
| 794 | role: element.getAttribute("role"), | ||
| 795 | name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", | ||
| 796 | selector: selectorFor(element), | ||
| 797 | x: rect.x, | ||
| 798 | y: rect.y, | ||
| 799 | width: rect.width, | ||
| 800 | height: rect.height | ||
| 801 | }; | ||
| 802 | }); | ||
| 803 | return { | ||
| 804 | url: location.href, | ||
| 805 | title: document.title, | ||
| 806 | loading: document.readyState !== "complete", | ||
| 807 | visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), | ||
| 808 | interactiveElements: elements | ||
| 809 | }; | ||
| 810 | })()`, | ||
| 811 | true, | ||
| 812 | ); | ||
| 813 | const accessibility = await send("Accessibility.getFullAXTree"); | ||
| 814 | let image = await wc.capturePage(); | ||
| 815 | let size = image.getSize(); | ||
| 816 | if (size.width > MAX_SCREENSHOT_WIDTH) { | ||
| 817 | image = image.resize({ width: MAX_SCREENSHOT_WIDTH }); | ||
| 818 | size = image.getSize(); | ||
| 819 | } | ||
| 820 | return { | ||
| 821 | ...page, | ||
| 822 | accessibilityTree: accessibility, | ||
| 823 | consoleEntries: [...(this.diagnostics.get(wc.id)?.consoleEntries ?? [])], | ||
| 824 | networkEntries: [...(this.diagnostics.get(wc.id)?.networkEntries ?? [])], | ||
| 825 | actionTimeline: [...(this.actionTimeline.get(tabId) ?? [])], | ||
| 826 | screenshot: { | ||
| 827 | mimeType: "image/png", | ||
| 828 | data: image.toPNG().toString("base64"), | ||
| 829 | width: size.width, | ||
| 830 | height: size.height, | ||
| 831 | }, | ||
| 832 | }; | ||
| 833 | }); | ||
| 834 | } | ||
| 835 | |||
| 836 | async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise<void> { | ||
| 837 | const wc = this.requireWebContents(tabId); | ||
| 838 | await this.withControlSession(tabId, wc, "click", async (send) => { | ||
| 839 | await Promise.all([ | ||
| 840 | send("Runtime.enable"), | ||
| 841 | send("Input.setIgnoreInputEvents", { ignore: false }), | ||
| 842 | ]); | ||
| 843 | let x: number; | ||
| 844 | let y: number; | ||
| 845 | if ("selector" in input || "locator" in input) { | ||
| 846 | await this.ensurePlaywrightInjected(send); | ||
| 847 | const locator = this.automationLocator(input); | ||
| 848 | const point = await this.evaluateWithDebugger< | ||
| 849 | { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } | ||
| 850 | >( | ||
| 851 | send, | ||
| 852 | `(() => { | ||
| 853 | try { | ||
| 854 | const injected = globalThis.__t3PlaywrightInjected; | ||
| 855 | const parsed = injected.parseSelector(${JSON.stringify(locator)}); | ||
| 856 | const element = injected.querySelector(parsed, document, true); | ||
| 857 | if (!element) return { notFound: true }; | ||
| 858 | const visible = injected.elementState(element, "visible"); | ||
| 859 | const enabled = injected.elementState(element, "enabled"); | ||
| 860 | if (!visible.matches || !enabled.matches) return { notFound: true }; | ||
| 861 | element.scrollIntoView({ block: "center", inline: "center" }); | ||
| 862 | const rect = element.getBoundingClientRect(); | ||
| 863 | return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; | ||
| 864 | } catch (error) { | ||
| 865 | return { invalidSelector: true, message: String(error) }; | ||
| 866 | } | ||
| 867 | })()`, | ||
| 868 | true, | ||
| 869 | ); | ||
| 870 | if ("invalidSelector" in point) { | ||
| 871 | throw automationError("PreviewAutomationInvalidSelectorError", point.message, { | ||
| 872 | selector: locator, | ||
| 873 | }); | ||
| 874 | } | ||
| 875 | if ("notFound" in point) { | ||
| 876 | throw automationError( | ||
| 877 | "PreviewAutomationExecutionError", | ||
| 878 | `No element matches locator ${locator}.`, | ||
| 879 | ); | ||
| 880 | } | ||
| 881 | x = point.x; | ||
| 882 | y = point.y; | ||
| 883 | } else { | ||
| 884 | x = input.x!; | ||
| 885 | y = input.y!; | ||
| 886 | } | ||
| 887 | const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>( | ||
| 888 | send, | ||
| 889 | "({ width: window.innerWidth, height: window.innerHeight })", | ||
| 890 | true, | ||
| 891 | ); | ||
| 892 | if (x < 0 || y < 0 || x > viewport.width || y > viewport.height) { | ||
| 893 | throw automationError( | ||
| 894 | "PreviewAutomationExecutionError", | ||
| 895 | `Click coordinates (${x}, ${y}) are outside the preview viewport.`, | ||
| 896 | ); | ||
| 897 | } | ||
| 898 | this.emitPointerEvent({ | ||
| 899 | tabId, | ||
| 900 | phase: "move", | ||
| 901 | x, | ||
| 902 | y, | ||
| 903 | sequence: this.pointerSequence++, | ||
| 904 | createdAt: new Date().toISOString(), | ||
| 905 | }); | ||
| 906 | await sleep(AGENT_CURSOR_MOVE_MS); | ||
| 907 | this.emitPointerEvent({ | ||
| 908 | tabId, | ||
| 909 | phase: "click", | ||
| 910 | x, | ||
| 911 | y, | ||
| 912 | sequence: this.pointerSequence++, | ||
| 913 | createdAt: new Date().toISOString(), | ||
| 914 | }); | ||
| 915 | await sleep(AGENT_CURSOR_CLICK_LEAD_MS); | ||
| 916 | this.expectAgentInput(tabId, { kind: "pointer", x, y, button: 0 }); | ||
| 917 | await send("Input.dispatchMouseEvent", { | ||
| 918 | type: "mousePressed", | ||
| 919 | x, | ||
| 920 | y, | ||
| 921 | button: "left", | ||
| 922 | clickCount: 1, | ||
| 923 | }); | ||
| 924 | await send("Input.dispatchMouseEvent", { | ||
| 925 | type: "mouseReleased", | ||
| 926 | x, | ||
| 927 | y, | ||
| 928 | button: "left", | ||
| 929 | clickCount: 1, | ||
| 930 | }); | ||
| 931 | }); | ||
| 932 | } | ||
| 933 | |||
| 934 | async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise<void> { | ||
| 935 | const wc = this.requireWebContents(tabId); | ||
| 936 | await this.withControlSession(tabId, wc, "type", async (send) => { | ||
| 937 | await send("Runtime.enable"); | ||
| 938 | const locator = this.automationLocator(input); | ||
| 939 | if (locator) await this.ensurePlaywrightInjected(send); | ||
| 940 | const focusResult = await this.evaluateWithDebugger< | ||
| 941 | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } | ||
| 942 | >( | ||
| 943 | send, | ||
| 944 | `(() => { | ||
| 945 | try { | ||
| 946 | const element = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "document.activeElement"}; | ||
| 947 | if (!element) return { notFound: true }; | ||
| 948 | element.focus(); | ||
| 949 | if (${input.clear ?? false}) { | ||
| 950 | if ("value" in element) element.value = ""; | ||
| 951 | else if (element.isContentEditable) element.textContent = ""; | ||
| 952 | element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" })); | ||
| 953 | } | ||
| 954 | return { ok: true }; | ||
| 955 | } catch (error) { | ||
| 956 | return { invalidSelector: true, message: String(error) }; | ||
| 957 | } | ||
| 958 | })()`, | ||
| 959 | true, | ||
| 960 | ); | ||
| 961 | if ("invalidSelector" in focusResult) { | ||
| 962 | throw automationError("PreviewAutomationInvalidSelectorError", focusResult.message, { | ||
| 963 | selector: input.selector ?? "", | ||
| 964 | }); | ||
| 965 | } | ||
| 966 | if ("notFound" in focusResult) { | ||
| 967 | throw automationError( | ||
| 968 | "PreviewAutomationExecutionError", | ||
| 969 | locator | ||
| 970 | ? `No element matches locator ${locator}.` | ||
| 971 | : "No element is focused in the preview.", | ||
| 972 | ); | ||
| 973 | } | ||
| 974 | await send("Input.insertText", { text: input.text }); | ||
| 975 | await this.evaluateWithDebugger( | ||
| 976 | send, | ||
| 977 | `(() => { | ||
| 978 | const element = document.activeElement; | ||
| 979 | element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(input.text)} })); | ||
| 980 | element?.dispatchEvent(new Event("change", { bubbles: true })); | ||
| 981 | })()`, | ||
| 982 | false, | ||
| 983 | ); | ||
| 984 | }); | ||
| 985 | } | ||
| 986 | |||
| 987 | async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise<void> { | ||
| 988 | const wc = this.requireWebContents(tabId); | ||
| 989 | await this.withControlSession(tabId, wc, "press", async (send) => { | ||
| 990 | const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { | ||
| 991 | switch (modifier) { | ||
| 992 | case "Alt": | ||
| 993 | return value | 1; | ||
| 994 | case "Control": | ||
| 995 | return value | 2; | ||
| 996 | case "Meta": | ||
| 997 | return value | 4; | ||
| 998 | case "Shift": | ||
| 999 | return value | 8; | ||
| 1000 | } | ||
| 1001 | }, 0); | ||
| 1002 | const key = input.key; | ||
| 1003 | const text = key.length === 1 ? key : undefined; | ||
| 1004 | const params = { | ||
| 1005 | key, | ||
| 1006 | code: key.length === 1 ? `Key${key.toUpperCase()}` : key, | ||
| 1007 | modifiers, | ||
| 1008 | ...(text ? { text, unmodifiedText: text } : {}), | ||
| 1009 | }; | ||
| 1010 | this.expectAgentInput(tabId, { kind: "key", key, code: params.code }); | ||
| 1011 | await send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); | ||
| 1012 | await send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); | ||
| 1013 | }); | ||
| 1014 | } | ||
| 1015 | |||
| 1016 | async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise<void> { | ||
| 1017 | const wc = this.requireWebContents(tabId); | ||
| 1018 | await this.withControlSession(tabId, wc, "scroll", async (send) => { | ||
| 1019 | await send("Runtime.enable"); | ||
| 1020 | const locator = this.automationLocator(input); | ||
| 1021 | if (locator) await this.ensurePlaywrightInjected(send); | ||
| 1022 | const result = await this.evaluateWithDebugger< | ||
| 1023 | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } | ||
| 1024 | >( | ||
| 1025 | send, | ||
| 1026 | `(() => { | ||
| 1027 | try { | ||
| 1028 | const target = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "window"}; | ||
| 1029 | if (!target) return { notFound: true }; | ||
| 1030 | target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); | ||
| 1031 | return { ok: true }; | ||
| 1032 | } catch (error) { | ||
| 1033 | return { invalidSelector: true, message: String(error) }; | ||
| 1034 | } | ||
| 1035 | })()`, | ||
| 1036 | true, | ||
| 1037 | ); | ||
| 1038 | if ("invalidSelector" in result) { | ||
| 1039 | throw automationError("PreviewAutomationInvalidSelectorError", result.message, { | ||
| 1040 | selector: input.selector ?? "", | ||
| 1041 | }); | ||
| 1042 | } | ||
| 1043 | if ("notFound" in result) { | ||
| 1044 | throw automationError( | ||
| 1045 | "PreviewAutomationExecutionError", | ||
| 1046 | `No element matches locator ${locator}.`, | ||
| 1047 | ); | ||
| 1048 | } | ||
| 1049 | }); | ||
| 1050 | } | ||
| 1051 | |||
| 1052 | async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<unknown> { | ||
| 1053 | const wc = this.requireWebContents(tabId); | ||
| 1054 | return this.withControlSession(tabId, wc, "evaluate", async (send) => { | ||
| 1055 | await send("Runtime.enable"); | ||
| 1056 | const value = await this.evaluateWithDebugger( | ||
| 1057 | send, | ||
| 1058 | input.expression, | ||
| 1059 | input.returnByValue ?? true, | ||
| 1060 | input.awaitPromise ?? true, | ||
| 1061 | ); | ||
| 1062 | const serialized = JSON.stringify(value); | ||
| 1063 | if ( | ||
| 1064 | serialized !== undefined && | ||
| 1065 | Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES | ||
| 1066 | ) { | ||
| 1067 | throw automationError( | ||
| 1068 | "PreviewAutomationResultTooLargeError", | ||
| 1069 | `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, | ||
| 1070 | { maximumBytes: MAX_EVALUATION_BYTES }, | ||
| 1071 | ); | ||
| 1072 | } | ||
| 1073 | return value; | ||
| 1074 | }); | ||
| 1075 | } | ||
| 1076 | |||
| 1077 | async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<void> { | ||
| 1078 | const wc = this.requireWebContents(tabId); | ||
| 1079 | const timeoutMs = input.timeoutMs ?? 15_000; | ||
| 1080 | await this.withControlSession(tabId, wc, "waitFor", async (send) => { | ||
| 1081 | await send("Runtime.enable"); | ||
| 1082 | const locator = this.automationLocator(input); | ||
| 1083 | if (locator) await this.ensurePlaywrightInjected(send); | ||
| 1084 | const deadline = Date.now() + timeoutMs; | ||
| 1085 | while (Date.now() <= deadline) { | ||
| 1086 | const result = await this.evaluateWithDebugger< | ||
| 1087 | { matched: boolean } | { invalidSelector: true; message: string } | ||
| 1088 | >( | ||
| 1089 | send, | ||
| 1090 | `(() => { | ||
| 1091 | try { | ||
| 1092 | const selectorMatched = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, false) !== null; })()` : "true"}; | ||
| 1093 | const textMatched = ${ | ||
| 1094 | input.text | ||
| 1095 | ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})` | ||
| 1096 | : "true" | ||
| 1097 | }; | ||
| 1098 | const urlMatched = ${ | ||
| 1099 | input.urlIncludes | ||
| 1100 | ? `location.href.includes(${JSON.stringify(input.urlIncludes)})` | ||
| 1101 | : "true" | ||
| 1102 | }; | ||
| 1103 | return { matched: selectorMatched && textMatched && urlMatched }; | ||
| 1104 | } catch (error) { | ||
| 1105 | return { invalidSelector: true, message: String(error) }; | ||
| 1106 | } | ||
| 1107 | })()`, | ||
| 1108 | true, | ||
| 1109 | ); | ||
| 1110 | if ("invalidSelector" in result) { | ||
| 1111 | throw automationError("PreviewAutomationInvalidSelectorError", result.message, { | ||
| 1112 | selector: input.selector ?? "", | ||
| 1113 | }); | ||
| 1114 | } | ||
| 1115 | if (result.matched) return; | ||
| 1116 | await sleep(100); | ||
| 1117 | } | ||
| 1118 | throw automationError( | ||
| 1119 | "PreviewAutomationTimeoutError", | ||
| 1120 | `Preview condition did not match within ${timeoutMs}ms.`, | ||
| 1121 | ); | ||
| 1122 | }); | ||
| 1123 | } | ||
| 1124 | |||
| 1125 | private applyZoom(tabId: string, transform: (current: number) => number): void { | ||
| 1126 | const tab = this.tabs.get(tabId); | ||
| 1127 | if (!tab) return; | ||
| 1128 | const next = transform(tab.zoomFactor); | ||
| 1129 | if (Math.abs(next - tab.zoomFactor) < ZOOM_EPSILON) return; | ||
| 1130 | if (tab.webContentsId != null) { | ||
| 1131 | const wc = webContents.fromId(tab.webContentsId); | ||
| 1132 | if (wc && !wc.isDestroyed()) { | ||
| 1133 | wc.setZoomFactor(next); | ||
| 1134 | } | ||
| 1135 | } | ||
| 1136 | this.update(tabId, { zoomFactor: next }); | ||
| 1137 | } | ||
| 1138 | |||
| 1139 | private async withControlSession<A>( | ||
| 1140 | tabId: string, | ||
| 1141 | wc: Electron.WebContents, | ||
| 1142 | action: string, | ||
| 1143 | use: ( | ||
| 1144 | send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, | ||
| 1145 | ) => Promise<A>, | ||
| 1146 | ): Promise<A> { | ||
| 1147 | const actionEvent: PreviewAutomationActionEvent = { | ||
| 1148 | id: `browser-action-${Date.now().toString(36)}-${(this.actionSequence++).toString(36)}`, | ||
| 1149 | action, | ||
| 1150 | status: "running", | ||
| 1151 | startedAt: new Date().toISOString(), | ||
| 1152 | }; | ||
| 1153 | this.pushAction(tabId, actionEvent); | ||
| 1154 | const epoch = this.controlEpoch.get(tabId) ?? 0; | ||
| 1155 | const control = await this.ensureControlSession(wc); | ||
| 1156 | let resolveTail: () => void = () => undefined; | ||
| 1157 | const previous = control.tail; | ||
| 1158 | control.tail = new Promise<void>((resolve) => { | ||
| 1159 | resolveTail = resolve; | ||
| 1160 | }); | ||
| 1161 | await previous; | ||
| 1162 | this.update(tabId, { controller: "agent" }); | ||
| 1163 | try { | ||
| 1164 | const send = async (method: string, commandParams?: Record<string, unknown>) => { | ||
| 1165 | if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) { | ||
| 1166 | throw automationError( | ||
| 1167 | "PreviewAutomationControlInterruptedError", | ||
| 1168 | "Browser control was interrupted by human input.", | ||
| 1169 | ); | ||
| 1170 | } | ||
| 1171 | const result = await wc.debugger.sendCommand(method, commandParams); | ||
| 1172 | if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) { | ||
| 1173 | throw automationError( | ||
| 1174 | "PreviewAutomationControlInterruptedError", | ||
| 1175 | "Browser control was interrupted by human input.", | ||
| 1176 | ); | ||
| 1177 | } | ||
| 1178 | return result; | ||
| 1179 | }; | ||
| 1180 | const result = await use(send); | ||
| 1181 | this.replaceAction(tabId, { | ||
| 1182 | ...actionEvent, | ||
| 1183 | status: "succeeded", | ||
| 1184 | completedAt: new Date().toISOString(), | ||
| 1185 | }); | ||
| 1186 | return result; | ||
| 1187 | } catch (cause) { | ||
| 1188 | const interrupted = | ||
| 1189 | cause instanceof Error && cause.name === "PreviewAutomationControlInterruptedError"; | ||
| 1190 | this.replaceAction(tabId, { | ||
| 1191 | ...actionEvent, | ||
| 1192 | status: interrupted ? "interrupted" : "failed", | ||
| 1193 | completedAt: new Date().toISOString(), | ||
| 1194 | error: cause instanceof Error ? cause.message : String(cause), | ||
| 1195 | }); | ||
| 1196 | if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause; | ||
| 1197 | throw automationError( | ||
| 1198 | "PreviewAutomationExecutionError", | ||
| 1199 | cause instanceof Error ? cause.message : String(cause), | ||
| 1200 | { tabId, cause }, | ||
| 1201 | ); | ||
| 1202 | } finally { | ||
| 1203 | if (this.tabs.has(tabId)) this.update(tabId, { controller: "none" }); | ||
| 1204 | resolveTail(); | ||
| 1205 | } | ||
| 1206 | } | ||
| 1207 | |||
| 1208 | private pushAction(tabId: string, event: PreviewAutomationActionEvent): void { | ||
| 1209 | const timeline = this.actionTimeline.get(tabId) ?? []; | ||
| 1210 | timeline.push(event); | ||
| 1211 | if (timeline.length > 200) timeline.splice(0, timeline.length - 200); | ||
| 1212 | this.actionTimeline.set(tabId, timeline); | ||
| 1213 | } | ||
| 1214 | |||
| 1215 | private replaceAction(tabId: string, event: PreviewAutomationActionEvent): void { | ||
| 1216 | const timeline = this.actionTimeline.get(tabId); | ||
| 1217 | if (!timeline) return; | ||
| 1218 | const index = timeline.findIndex((candidate) => candidate.id === event.id); | ||
| 1219 | if (index >= 0) timeline[index] = event; | ||
| 1220 | } | ||
| 1221 | |||
| 1222 | private async ensureControlSession(wc: Electron.WebContents): Promise<BrowserControlSession> { | ||
| 1223 | const existing = this.controlSessions.get(wc.id); | ||
| 1224 | if (existing) { | ||
| 1225 | await existing.initialized; | ||
| 1226 | return existing; | ||
| 1227 | } | ||
| 1228 | if (wc.isDevToolsOpened()) { | ||
| 1229 | throw automationError( | ||
| 1230 | "PreviewAutomationExecutionError", | ||
| 1231 | "Close preview DevTools before using agent browser control.", | ||
| 1232 | ); | ||
| 1233 | } | ||
| 1234 | if (wc.debugger.isAttached()) { | ||
| 1235 | throw automationError( | ||
| 1236 | "PreviewAutomationExecutionError", | ||
| 1237 | "Preview control cannot attach because another debugger owns this page.", | ||
| 1238 | ); | ||
| 1239 | } | ||
| 1240 | const diagnostics: BrowserDiagnostics = { | ||
| 1241 | consoleEntries: [], | ||
| 1242 | networkEntries: [], | ||
| 1243 | requests: new Map(), | ||
| 1244 | }; | ||
| 1245 | this.diagnostics.set(wc.id, diagnostics); | ||
| 1246 | const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { | ||
| 1247 | if (method === "Page.screencastFrame") { | ||
| 1248 | const frame = params; | ||
| 1249 | const sessionId = frame["sessionId"]; | ||
| 1250 | if (typeof sessionId === "number") { | ||
| 1251 | void wc.debugger | ||
| 1252 | .sendCommand("Page.screencastFrameAck", { sessionId }) | ||
| 1253 | .catch(() => undefined); | ||
| 1254 | } | ||
| 1255 | const tabId = this.tabIdForWebContents(wc.id); | ||
| 1256 | const metadata = | ||
| 1257 | typeof frame["metadata"] === "object" && frame["metadata"] !== null | ||
| 1258 | ? (frame["metadata"] as Record<string, unknown>) | ||
| 1259 | : {}; | ||
| 1260 | if (tabId && typeof frame["data"] === "string") { | ||
| 1261 | const payload: DesktopPreviewRecordingFrame = { | ||
| 1262 | tabId, | ||
| 1263 | data: frame["data"], | ||
| 1264 | width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, | ||
| 1265 | height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, | ||
| 1266 | receivedAt: new Date().toISOString(), | ||
| 1267 | }; | ||
| 1268 | for (const listener of this.recordingFrameListeners) listener(payload); | ||
| 1269 | } | ||
| 1270 | } | ||
| 1271 | this.captureDiagnosticMessage(diagnostics, method, params); | ||
| 1272 | }; | ||
| 1273 | const control: BrowserControlSession = { | ||
| 1274 | webContentsId: wc.id, | ||
| 1275 | tail: Promise.resolve(), | ||
| 1276 | initialized: Promise.resolve(), | ||
| 1277 | onMessage, | ||
| 1278 | }; | ||
| 1279 | wc.debugger.on("message", onMessage); | ||
| 1280 | control.initialized = (async () => { | ||
| 1281 | wc.debugger.attach("1.3"); | ||
| 1282 | await Promise.all([ | ||
| 1283 | wc.debugger.sendCommand("Runtime.enable"), | ||
| 1284 | wc.debugger.sendCommand("Accessibility.enable"), | ||
| 1285 | wc.debugger.sendCommand("Network.enable"), | ||
| 1286 | wc.debugger.sendCommand("Log.enable"), | ||
| 1287 | ]); | ||
| 1288 | })(); | ||
| 1289 | this.controlSessions.set(wc.id, control); | ||
| 1290 | try { | ||
| 1291 | await control.initialized; | ||
| 1292 | return control; | ||
| 1293 | } catch (cause) { | ||
| 1294 | this.controlSessions.delete(wc.id); | ||
| 1295 | throw cause; | ||
| 1296 | } | ||
| 1297 | } | ||
| 1298 | |||
| 1299 | private detachControlSession(webContentsId: number): void { | ||
| 1300 | const control = this.controlSessions.get(webContentsId); | ||
| 1301 | this.controlSessions.delete(webContentsId); | ||
| 1302 | this.diagnostics.delete(webContentsId); | ||
| 1303 | const wc = webContents.fromId(webContentsId); | ||
| 1304 | if (!wc || wc.isDestroyed()) return; | ||
| 1305 | if (control) { | ||
| 1306 | wc.debugger.off("message", control.onMessage); | ||
| 1307 | } | ||
| 1308 | if (!wc.debugger.isAttached()) return; | ||
| 1309 | try { | ||
| 1310 | wc.debugger.detach(); | ||
| 1311 | } catch { | ||
| 1312 | // Target teardown can race detachment. | ||
| 1313 | } | ||
| 1314 | } | ||
| 1315 | |||
| 1316 | private tabIdForWebContents(webContentsId: number): string | null { | ||
| 1317 | for (const [tabId, tab] of this.tabs) { | ||
| 1318 | if (tab.webContentsId === webContentsId) return tabId; | ||
| 1319 | } | ||
| 1320 | return null; | ||
| 1321 | } | ||
| 1322 | |||
| 1323 | private captureDiagnosticMessage( | ||
| 1324 | diagnostics: BrowserDiagnostics, | ||
| 1325 | method: string, | ||
| 1326 | params: Record<string, unknown>, | ||
| 1327 | ): void { | ||
| 1328 | const timestamp = new Date().toISOString(); | ||
| 1329 | if (method === "Runtime.consoleAPICalled") { | ||
| 1330 | const args = Array.isArray(params["args"]) ? params["args"] : []; | ||
| 1331 | const text = args | ||
| 1332 | .map((arg) => { | ||
| 1333 | if (typeof arg !== "object" || arg === null) return String(arg); | ||
| 1334 | const value = arg as Record<string, unknown>; | ||
| 1335 | return String(value["value"] ?? value["description"] ?? ""); | ||
| 1336 | }) | ||
| 1337 | .join(" "); | ||
| 1338 | this.pushBounded(diagnostics.consoleEntries, { | ||
| 1339 | level: typeof params["type"] === "string" ? params["type"] : "log", | ||
| 1340 | text, | ||
| 1341 | timestamp, | ||
| 1342 | source: "console", | ||
| 1343 | }); | ||
| 1344 | return; | ||
| 1345 | } | ||
| 1346 | if (method === "Runtime.exceptionThrown") { | ||
| 1347 | const details = | ||
| 1348 | typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null | ||
| 1349 | ? (params["exceptionDetails"] as Record<string, unknown>) | ||
| 1350 | : {}; | ||
| 1351 | this.pushBounded(diagnostics.consoleEntries, { | ||
| 1352 | level: "error", | ||
| 1353 | text: String(details["text"] ?? "Uncaught exception"), | ||
| 1354 | timestamp, | ||
| 1355 | source: "exception", | ||
| 1356 | }); | ||
| 1357 | return; | ||
| 1358 | } | ||
| 1359 | if (method === "Log.entryAdded") { | ||
| 1360 | const entry = | ||
| 1361 | typeof params["entry"] === "object" && params["entry"] !== null | ||
| 1362 | ? (params["entry"] as Record<string, unknown>) | ||
| 1363 | : {}; | ||
| 1364 | this.pushBounded(diagnostics.consoleEntries, { | ||
| 1365 | level: typeof entry["level"] === "string" ? entry["level"] : "info", | ||
| 1366 | text: String(entry["text"] ?? ""), | ||
| 1367 | timestamp, | ||
| 1368 | source: typeof entry["source"] === "string" ? entry["source"] : "log", | ||
| 1369 | }); | ||
| 1370 | return; | ||
| 1371 | } | ||
| 1372 | const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null; | ||
| 1373 | if (method === "Network.requestWillBeSent" && requestId) { | ||
| 1374 | const request = | ||
| 1375 | typeof params["request"] === "object" && params["request"] !== null | ||
| 1376 | ? (params["request"] as Record<string, unknown>) | ||
| 1377 | : {}; | ||
| 1378 | diagnostics.requests.set(requestId, { | ||
| 1379 | url: String(request["url"] ?? ""), | ||
| 1380 | method: String(request["method"] ?? "GET"), | ||
| 1381 | }); | ||
| 1382 | return; | ||
| 1383 | } | ||
| 1384 | if (method === "Network.responseReceived" && requestId) { | ||
| 1385 | const request = diagnostics.requests.get(requestId); | ||
| 1386 | const response = | ||
| 1387 | typeof params["response"] === "object" && params["response"] !== null | ||
| 1388 | ? (params["response"] as Record<string, unknown>) | ||
| 1389 | : {}; | ||
| 1390 | const status = typeof response["status"] === "number" ? response["status"] : null; | ||
| 1391 | if (request && status !== null && status >= 400) { | ||
| 1392 | this.pushBounded(diagnostics.networkEntries, { | ||
| 1393 | ...request, | ||
| 1394 | status, | ||
| 1395 | failed: true, | ||
| 1396 | timestamp, | ||
| 1397 | }); | ||
| 1398 | } | ||
| 1399 | return; | ||
| 1400 | } | ||
| 1401 | if (method === "Network.loadingFailed" && requestId) { | ||
| 1402 | const request = diagnostics.requests.get(requestId); | ||
| 1403 | if (request) { | ||
| 1404 | this.pushBounded(diagnostics.networkEntries, { | ||
| 1405 | ...request, | ||
| 1406 | status: null, | ||
| 1407 | failed: true, | ||
| 1408 | errorText: String(params["errorText"] ?? "Network request failed"), | ||
| 1409 | timestamp, | ||
| 1410 | }); | ||
| 1411 | } | ||
| 1412 | diagnostics.requests.delete(requestId); | ||
| 1413 | return; | ||
| 1414 | } | ||
| 1415 | if (method === "Network.loadingFinished" && requestId) diagnostics.requests.delete(requestId); | ||
| 1416 | } | ||
| 1417 | |||
| 1418 | private pushBounded<A>(buffer: A[], entry: A): void { | ||
| 1419 | buffer.push(entry); | ||
| 1420 | if (buffer.length > DIAGNOSTIC_BUFFER_LIMIT) { | ||
| 1421 | buffer.splice(0, buffer.length - DIAGNOSTIC_BUFFER_LIMIT); | ||
| 1422 | } | ||
| 1423 | } | ||
| 1424 | |||
| 1425 | private async evaluateWithDebugger<A = unknown>( | ||
| 1426 | send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, | ||
| 1427 | expression: string, | ||
| 1428 | returnByValue: boolean, | ||
| 1429 | awaitPromise = true, | ||
| 1430 | ): Promise<A> { | ||
| 1431 | const response = (await send("Runtime.evaluate", { | ||
| 1432 | expression, | ||
| 1433 | awaitPromise, | ||
| 1434 | returnByValue, | ||
| 1435 | userGesture: true, | ||
| 1436 | })) as CdpEvaluationResult; | ||
| 1437 | if (response.exceptionDetails) { | ||
| 1438 | throw automationError( | ||
| 1439 | "PreviewAutomationExecutionError", | ||
| 1440 | response.exceptionDetails.exception?.description ?? | ||
| 1441 | response.exceptionDetails.text ?? | ||
| 1442 | "JavaScript evaluation failed.", | ||
| 1443 | ); | ||
| 1444 | } | ||
| 1445 | return response.result?.value as A; | ||
| 1446 | } | ||
| 1447 | |||
| 1448 | private automationLocator(input: { | ||
| 1449 | readonly selector?: string | undefined; | ||
| 1450 | readonly locator?: string | undefined; | ||
| 1451 | }): string | null { | ||
| 1452 | if (input.locator) return input.locator; | ||
| 1453 | if (input.selector) return `css=${input.selector}`; | ||
| 1454 | return null; | ||
| 1455 | } | ||
| 1456 | |||
| 1457 | private async ensurePlaywrightInjected( | ||
| 1458 | send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, | ||
| 1459 | ): Promise<void> { | ||
| 1460 | const installed = await this.evaluateWithDebugger<boolean>( | ||
| 1461 | send, | ||
| 1462 | "Boolean(globalThis.__t3PlaywrightInjected)", | ||
| 1463 | true, | ||
| 1464 | ); | ||
| 1465 | if (installed) return; | ||
| 1466 | const expression = await playwrightInjectedRuntimeInstallExpression(); | ||
| 1467 | await this.evaluateWithDebugger(send, expression, true); | ||
| 1468 | } | ||
| 1469 | |||
| 1470 | onStateChange(listener: Listener): () => void { | ||
| 1471 | this.listeners.add(listener); | ||
| 1472 | return () => { | ||
| 1473 | this.listeners.delete(listener); | ||
| 1474 | }; | ||
| 1475 | } | ||
| 1476 | |||
| 1477 | onPointerEvent(listener: PointerEventListener): () => void { | ||
| 1478 | this.pointerEventListeners.add(listener); | ||
| 1479 | return () => { | ||
| 1480 | this.pointerEventListeners.delete(listener); | ||
| 1481 | }; | ||
| 1482 | } | ||
| 1483 | |||
| 1484 | private emitPointerEvent(event: DesktopPreviewPointerEvent): void { | ||
| 1485 | for (const listener of this.pointerEventListeners) listener(event); | ||
| 1486 | } | ||
| 1487 | |||
| 1488 | private expectAgentInput(tabId: string, signal: PreviewInputSignal): void { | ||
| 1489 | const now = Date.now(); | ||
| 1490 | const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter( | ||
| 1491 | (expected) => expected.expiresAt > now, | ||
| 1492 | ); | ||
| 1493 | pending.push({ signal, expiresAt: now + 1_000 }); | ||
| 1494 | this.expectedAgentInputs.set(tabId, pending); | ||
| 1495 | } | ||
| 1496 | |||
| 1497 | private consumeExpectedAgentInput(tabId: string, signal: PreviewInputSignal): boolean { | ||
| 1498 | const now = Date.now(); | ||
| 1499 | const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter( | ||
| 1500 | (expected) => expected.expiresAt > now, | ||
| 1501 | ); | ||
| 1502 | const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal)); | ||
| 1503 | if (index < 0) { | ||
| 1504 | if (pending.length === 0) this.expectedAgentInputs.delete(tabId); | ||
| 1505 | else this.expectedAgentInputs.set(tabId, pending); | ||
| 1506 | return false; | ||
| 1507 | } | ||
| 1508 | pending.splice(index, 1); | ||
| 1509 | if (pending.length === 0) this.expectedAgentInputs.delete(tabId); | ||
| 1510 | else this.expectedAgentInputs.set(tabId, pending); | ||
| 1511 | return true; | ||
| 1512 | } | ||
| 1513 | |||
| 1514 | destroy(): void { | ||
| 1515 | for (const tabId of Array.from(this.tabs.keys())) { | ||
| 1516 | this.closeTab(tabId); | ||
| 1517 | } | ||
| 1518 | this.listeners.clear(); | ||
| 1519 | this.expectedAgentInputs.clear(); | ||
| 1520 | this.pointerEventListeners.clear(); | ||
| 1521 | this.recordingFrameListeners.clear(); | ||
| 1522 | } | ||
| 1523 | |||
| 1524 | private attachListeners(tabId: string, wc: Electron.WebContents): void { | ||
| 1525 | const sync = () => { | ||
| 1526 | if (wc.isDestroyed()) return; | ||
| 1527 | this.update(tabId, { | ||
| 1528 | navStatus: this.computeNavStatus(wc), | ||
| 1529 | canGoBack: wc.navigationHistory.canGoBack(), | ||
| 1530 | canGoForward: wc.navigationHistory.canGoForward(), | ||
| 1531 | }); | ||
| 1532 | }; | ||
| 1533 | const failed = (_event: Event, code: number, description: string): void => { | ||
| 1534 | // -3 = ABORTED (user navigated away mid-load); ignore. | ||
| 1535 | if (code === -3) return; | ||
| 1536 | this.update(tabId, { | ||
| 1537 | navStatus: { | ||
| 1538 | kind: "LoadFailed", | ||
| 1539 | url: wc.getURL(), | ||
| 1540 | title: wc.getTitle(), | ||
| 1541 | code, | ||
| 1542 | description, | ||
| 1543 | }, | ||
| 1544 | }); | ||
| 1545 | }; | ||
| 1546 | const humanInput = (_event: unknown, rawSignal?: unknown): void => { | ||
| 1547 | if (isPreviewInputSignal(rawSignal) && this.consumeExpectedAgentInput(tabId, rawSignal)) { | ||
| 1548 | return; | ||
| 1549 | } | ||
| 1550 | this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1); | ||
| 1551 | this.update(tabId, { controller: "human" }); | ||
| 1552 | void sleep(750).then(() => { | ||
| 1553 | if (this.tabs.get(tabId)?.controller === "human") { | ||
| 1554 | this.update(tabId, { controller: "none" }); | ||
| 1555 | } | ||
| 1556 | }); | ||
| 1557 | }; | ||
| 1558 | |||
| 1559 | wc.on("did-navigate", sync); | ||
| 1560 | wc.on("did-navigate-in-page", sync); | ||
| 1561 | wc.on("page-title-updated", sync); | ||
| 1562 | wc.on("did-start-loading", sync); | ||
| 1563 | wc.on("did-stop-loading", sync); | ||
| 1564 | wc.on("did-fail-load", failed as never); | ||
| 1565 | wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); | ||
| 1566 | |||
| 1567 | // Keep external links inside the same view (matches ami's policy). | ||
| 1568 | wc.setWindowOpenHandler(({ url }) => { | ||
| 1569 | void wc.loadURL(url); | ||
| 1570 | return { action: "deny" }; | ||
| 1571 | }); | ||
| 1572 | |||
| 1573 | // Forward app-level shortcuts to the main window so mod+shift+J etc. | ||
| 1574 | // still toggles the preview panel even when the webview has focus. | ||
| 1575 | const beforeInput = (event: Electron.Event, input: Electron.Input): void => { | ||
| 1576 | if (this.isAppShortcut(input) && this.mainWindow && !this.mainWindow.isDestroyed()) { | ||
| 1577 | event.preventDefault(); | ||
| 1578 | this.mainWindow.webContents.sendInputEvent({ | ||
| 1579 | type: "keyDown", | ||
| 1580 | keyCode: input.key, | ||
| 1581 | modifiers: [ | ||
| 1582 | ...(input.meta ? (["meta"] as const) : []), | ||
| 1583 | ...(input.shift ? (["shift"] as const) : []), | ||
| 1584 | ...(input.control ? (["control"] as const) : []), | ||
| 1585 | ...(input.alt ? (["alt"] as const) : []), | ||
| 1586 | ], | ||
| 1587 | }); | ||
| 1588 | } | ||
| 1589 | }; | ||
| 1590 | wc.on("before-input-event", beforeInput); | ||
| 1591 | |||
| 1592 | this.attached.set(wc.id, { navigate: sync, failed, humanInput, beforeInput }); | ||
| 1593 | } | ||
| 1594 | |||
| 1595 | private detachListeners(webContentsId: number): void { | ||
| 1596 | const handlers = this.attached.get(webContentsId); | ||
| 1597 | if (!handlers) return; | ||
| 1598 | this.attached.delete(webContentsId); | ||
| 1599 | const wc = webContents.fromId(webContentsId); | ||
| 1600 | if (!wc || wc.isDestroyed()) return; | ||
| 1601 | wc.off("did-navigate", handlers.navigate); | ||
| 1602 | wc.off("did-navigate-in-page", handlers.navigate); | ||
| 1603 | wc.off("page-title-updated", handlers.navigate); | ||
| 1604 | wc.off("did-start-loading", handlers.navigate); | ||
| 1605 | wc.off("did-stop-loading", handlers.navigate); | ||
| 1606 | wc.off("did-fail-load", handlers.failed as never); | ||
| 1607 | wc.off("before-input-event", handlers.beforeInput); | ||
| 1608 | wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput); | ||
| 1609 | } | ||
| 1610 | |||
| 1611 | private isAppShortcut(input: Electron.Input): boolean { | ||
| 1612 | if (input.type !== "keyDown") return false; | ||
| 1613 | return APP_FORWARDED_SHORTCUTS.some( | ||
| 1614 | (shortcut) => | ||
| 1615 | shortcut.key.toLowerCase() === input.key.toLowerCase() && | ||
| 1616 | shortcut.meta === input.meta && | ||
| 1617 | shortcut.shift === input.shift && | ||
| 1618 | shortcut.control === input.control, | ||
| 1619 | ); | ||
| 1620 | } | ||
| 1621 | |||
| 1622 | private computeNavStatus(wc: Electron.WebContents): PreviewNavStatus { | ||
| 1623 | const url = wc.getURL(); | ||
| 1624 | const title = wc.getTitle(); | ||
| 1625 | if (url === "" || url === "about:blank") return { kind: "Idle" }; | ||
| 1626 | if (wc.isLoading()) return { kind: "Loading", url, title }; | ||
| 1627 | return { kind: "Success", url, title }; | ||
| 1628 | } | ||
| 1629 | |||
| 1630 | private requireWebContents(tabId: string): Electron.WebContents { | ||
| 1631 | const tab = this.tabs.get(tabId); | ||
| 1632 | if (!tab) throw new PreviewTabNotFoundError(tabId); | ||
| 1633 | if (tab.webContentsId == null) throw new PreviewWebviewNotInitializedError(tabId); | ||
| 1634 | const wc = webContents.fromId(tab.webContentsId); | ||
| 1635 | if (!wc) throw new PreviewWebContentsNotFoundError(tabId, tab.webContentsId); | ||
| 1636 | return wc; | ||
| 1637 | } | ||
| 1638 | |||
| 1639 | private update(tabId: string, patch: Partial<PreviewTabState>): void { | ||
| 1640 | const current = this.tabs.get(tabId); | ||
| 1641 | if (!current) return; | ||
| 1642 | const next: PreviewTabState = { | ||
| 1643 | ...current, | ||
| 1644 | ...patch, | ||
| 1645 | updatedAt: new Date().toISOString(), | ||
| 1646 | }; | ||
| 1647 | this.tabs.set(tabId, next); | ||
| 1648 | this.emit(tabId, next); | ||
| 1649 | } | ||
| 1650 | |||
| 1651 | private emit(tabId: string, state: PreviewTabState): void { | ||
| 1652 | for (const listener of this.listeners) { | ||
| 1653 | try { | ||
| 1654 | listener(tabId, state); | ||
| 1655 | } catch { | ||
| 1656 | // listener errors must not crash the manager | ||
| 1657 | } | ||
| 1658 | } | ||
| 1659 | } | ||
| 1660 | |||
| 1661 | private normalizeUrl(input: string): string { | ||
| 1662 | // Surface the shared error directly so the IPC caller (and any future | ||
| 1663 | // desktop-side telemetry) gets the `detail` field for free. Defining a | ||
| 1664 | // bespoke desktop class would just lose information. | ||
| 1665 | return normalizePreviewUrl(input); | ||
| 1666 | } | ||
| 1667 | } | ||
| 1668 | |||
| 1669 | export class PreviewTabNotFoundError extends Error { | ||
| 1670 | readonly tabId: string; | ||
| 1671 | constructor(tabId: string) { | ||
| 1672 | super(`Preview tab not found: ${tabId}`); | ||
| 1673 | this.name = "PreviewTabNotFoundError"; | ||
| 1674 | this.tabId = tabId; | ||
| 1675 | } | ||
| 1676 | } | ||
| 1677 | |||
| 1678 | export class PreviewWebContentsNotFoundError extends Error { | ||
| 1679 | readonly tabId: string; | ||
| 1680 | readonly webContentsId: number; | ||
| 1681 | constructor(tabId: string, webContentsId: number) { | ||
| 1682 | super(`WebContents ${webContentsId} not found for preview tab ${tabId}`); | ||
| 1683 | this.name = "PreviewWebContentsNotFoundError"; | ||
| 1684 | this.tabId = tabId; | ||
| 1685 | this.webContentsId = webContentsId; | ||
| 1686 | } | ||
| 1687 | } | ||
| 1688 | |||
| 1689 | export class PreviewWebviewNotInitializedError extends Error { | ||
| 1690 | readonly tabId: string; | ||
| 1691 | constructor(tabId: string) { | ||
| 1692 | super(`Preview tab "${tabId}" has no webview registered`); | ||
| 1693 | this.name = "PreviewWebviewNotInitializedError"; | ||
| 1694 | this.tabId = tabId; | ||
| 1695 | } | ||
| 1696 | } | ||
| 1697 | |||
| 1698 | export class PreviewManagerError extends Data.TaggedError("PreviewManagerError")<{ | ||
| 1699 | readonly operation: string; | ||
| 1700 | readonly cause: unknown; | ||
| 1701 | }> { | ||
| 1702 | override get message() { | ||
| 1703 | return `Desktop preview operation failed: ${this.operation}`; | ||
| 1704 | } | ||
| 1705 | } | ||
| 1706 | |||
| 1707 | export interface PreviewManagerShape { | ||
| 1708 | readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>; | ||
| 1709 | readonly getBrowserSession: (scope?: string) => Effect.Effect<Session, PreviewManagerError>; | ||
| 1710 | readonly isBrowserPartition: (partition: string) => boolean; | ||
| 1711 | readonly createTab: (tabId: string) => Effect.Effect<PreviewTabState, PreviewManagerError>; | ||
| 1712 | readonly closeTab: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | ||
| 1713 | readonly registerWebview: ( | ||
| 1714 | tabId: string, | ||
| 1715 | webContentsId: number, | ||
| 1716 | ) => Effect.Effect<void, PreviewManagerError>; | ||
| 1717 | readonly navigate: (tabId: string, url: string) => Effect.Effect<void, PreviewManagerError>; | ||
| 1718 | readonly goBack: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | ||
| 1719 | readonly goForward: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | ||
| 1720 | readonly refresh: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | ||
| 1721 | readonly zoomIn: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | ||
| 1722 | readonly zoomOut: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | ||
| 1723 | readonly resetZoom: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | ||
| 1724 | readonly hardReload: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | ||
| 1725 | readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | ||
| 1726 | readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>; | ||
| 1727 | readonly clearCache: () => Effect.Effect<void, PreviewManagerError>; | ||
| 1728 | readonly getBrowserPartition: (scope?: string) => string; | ||
| 1729 | readonly setAnnotationTheme: ( | ||
| 1730 | theme: DesktopPreviewAnnotationTheme, | ||
| 1731 | ) => Effect.Effect<void, PreviewManagerError>; | ||
| 1732 | readonly pickElement: ( | ||
| 1733 | tabId: string, | ||
| 1734 | ) => Effect.Effect<PreviewAnnotationPayload | null, PreviewManagerError>; | ||
| 1735 | readonly cancelPickElement: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | ||
| 1736 | readonly captureScreenshot: ( | ||
| 1737 | tabId: string, | ||
| 1738 | ) => Effect.Effect<DesktopPreviewScreenshotArtifact, PreviewManagerError>; | ||
| 1739 | readonly revealArtifact: (path: string) => Effect.Effect<void, PreviewManagerError>; | ||
| 1740 | readonly copyArtifactToClipboard: (path: string) => Effect.Effect<void, PreviewManagerError>; | ||
| 1741 | readonly startRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | ||
| 1742 | readonly stopRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | ||
| 1743 | readonly saveRecording: ( | ||
| 1744 | tabId: string, | ||
| 1745 | mimeType: string, | ||
| 1746 | data: Uint8Array, | ||
| 1747 | ) => Effect.Effect<DesktopPreviewRecordingArtifact, PreviewManagerError>; | ||
| 1748 | readonly automationStatus: ( | ||
| 1749 | tabId: string, | ||
| 1750 | ) => Effect.Effect<PreviewAutomationStatus, PreviewManagerError>; | ||
| 1751 | readonly automationSnapshot: ( | ||
| 1752 | tabId: string, | ||
| 1753 | ) => Effect.Effect<PreviewAutomationSnapshot, PreviewManagerError>; | ||
| 1754 | readonly automationClick: ( | ||
| 1755 | tabId: string, | ||
| 1756 | input: PreviewAutomationClickInput, | ||
| 1757 | ) => Effect.Effect<void, PreviewManagerError>; | ||
| 1758 | readonly automationType: ( | ||
| 1759 | tabId: string, | ||
| 1760 | input: PreviewAutomationTypeInput, | ||
| 1761 | ) => Effect.Effect<void, PreviewManagerError>; | ||
| 1762 | readonly automationPress: ( | ||
| 1763 | tabId: string, | ||
| 1764 | input: PreviewAutomationPressInput, | ||
| 1765 | ) => Effect.Effect<void, PreviewManagerError>; | ||
| 1766 | readonly automationScroll: ( | ||
| 1767 | tabId: string, | ||
| 1768 | input: PreviewAutomationScrollInput, | ||
| 1769 | ) => Effect.Effect<void, PreviewManagerError>; | ||
| 1770 | readonly automationEvaluate: ( | ||
| 1771 | tabId: string, | ||
| 1772 | input: PreviewAutomationEvaluateInput, | ||
| 1773 | ) => Effect.Effect<unknown, PreviewManagerError>; | ||
| 1774 | readonly automationWaitFor: ( | ||
| 1775 | tabId: string, | ||
| 1776 | input: PreviewAutomationWaitForInput, | ||
| 1777 | ) => Effect.Effect<void, PreviewManagerError>; | ||
| 1778 | readonly subscribeStateChanges: (listener: Listener) => Effect.Effect<void, never, Scope.Scope>; | ||
| 1779 | readonly subscribePointerEvents: ( | ||
| 1780 | listener: PointerEventListener, | ||
| 1781 | ) => Effect.Effect<void, never, Scope.Scope>; | ||
| 1782 | readonly subscribeRecordingFrames: ( | ||
| 1783 | listener: RecordingFrameListener, | ||
| 1784 | ) => Effect.Effect<void, never, Scope.Scope>; | ||
| 1785 | } | ||
| 1786 | |||
| 1787 | export class PreviewManager extends Context.Service<PreviewManager, PreviewManagerShape>()( | ||
| 1788 | "@t3tools/desktop/preview/Manager/PreviewManager", | ||
| 1789 | ) {} | ||
| 1790 | |||
| 1791 | const make = Effect.fn("PreviewManager.make")(function* () { | ||
| 1792 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 1793 | const browserSession = yield* BrowserSession.BrowserSession; | ||
| 1794 | const manager = new PreviewViewManager(); | ||
| 1795 | manager.configureArtifactDirectory(environment.browserArtifactsDir); | ||
| 1796 | yield* Effect.addFinalizer(() => Effect.sync(() => manager.destroy())); | ||
| 1797 | |||
| 1798 | const attempt = <A>( | ||
| 1799 | operation: string, | ||
| 1800 | evaluate: () => A, | ||
| 1801 | ): Effect.Effect<A, PreviewManagerError> => | ||
| 1802 | Effect.try({ | ||
| 1803 | try: evaluate, | ||
| 1804 | catch: (cause) => new PreviewManagerError({ operation, cause }), | ||
| 1805 | }); | ||
| 1806 | const attemptPromise = <A>( | ||
| 1807 | operation: string, | ||
| 1808 | evaluate: () => Promise<A>, | ||
| 1809 | ): Effect.Effect<A, PreviewManagerError> => | ||
| 1810 | Effect.tryPromise({ | ||
| 1811 | try: evaluate, | ||
| 1812 | catch: (cause) => new PreviewManagerError({ operation, cause }), | ||
| 1813 | }); | ||
| 1814 | const browserSessionEffect = <A>( | ||
| 1815 | operation: string, | ||
| 1816 | effect: Effect.Effect<A, BrowserSession.BrowserSessionError>, | ||
| 1817 | ): Effect.Effect<A, PreviewManagerError> => | ||
| 1818 | effect.pipe(Effect.mapError((cause) => new PreviewManagerError({ operation, cause }))); | ||
| 1819 | |||
| 1820 | return PreviewManager.of({ | ||
| 1821 | setMainWindow: Effect.fn("PreviewManager.setMainWindow")(function* (window) { | ||
| 1822 | yield* attempt("setMainWindow", () => manager.setMainWindow(window)); | ||
| 1823 | }), | ||
| 1824 | getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) { | ||
| 1825 | return yield* browserSessionEffect("getBrowserSession", browserSession.getSession(scope)); | ||
| 1826 | }), | ||
| 1827 | isBrowserPartition: browserSession.isPartition, | ||
| 1828 | createTab: Effect.fn("PreviewManager.createTab")(function* (tabId) { | ||
| 1829 | return yield* attempt("createTab", () => manager.createTab(tabId)); | ||
| 1830 | }), | ||
| 1831 | closeTab: Effect.fn("PreviewManager.closeTab")(function* (tabId) { | ||
| 1832 | yield* attempt("closeTab", () => manager.closeTab(tabId)); | ||
| 1833 | }), | ||
| 1834 | registerWebview: Effect.fn("PreviewManager.registerWebview")(function* (tabId, webContentsId) { | ||
| 1835 | yield* attempt("registerWebview", () => manager.registerWebview(tabId, webContentsId)); | ||
| 1836 | }), | ||
| 1837 | navigate: Effect.fn("PreviewManager.navigate")(function* (tabId, url) { | ||
| 1838 | yield* attemptPromise("navigate", () => manager.navigate(tabId, url)); | ||
| 1839 | }), | ||
| 1840 | goBack: Effect.fn("PreviewManager.goBack")(function* (tabId) { | ||
| 1841 | yield* attempt("goBack", () => manager.goBack(tabId)); | ||
| 1842 | }), | ||
| 1843 | goForward: Effect.fn("PreviewManager.goForward")(function* (tabId) { | ||
| 1844 | yield* attempt("goForward", () => manager.goForward(tabId)); | ||
| 1845 | }), | ||
| 1846 | refresh: Effect.fn("PreviewManager.refresh")(function* (tabId) { | ||
| 1847 | yield* attempt("refresh", () => manager.refresh(tabId)); | ||
| 1848 | }), | ||
| 1849 | zoomIn: Effect.fn("PreviewManager.zoomIn")(function* (tabId) { | ||
| 1850 | yield* attempt("zoomIn", () => manager.zoomIn(tabId)); | ||
| 1851 | }), | ||
| 1852 | zoomOut: Effect.fn("PreviewManager.zoomOut")(function* (tabId) { | ||
| 1853 | yield* attempt("zoomOut", () => manager.zoomOut(tabId)); | ||
| 1854 | }), | ||
| 1855 | resetZoom: Effect.fn("PreviewManager.resetZoom")(function* (tabId) { | ||
| 1856 | yield* attempt("resetZoom", () => manager.resetZoom(tabId)); | ||
| 1857 | }), | ||
| 1858 | hardReload: Effect.fn("PreviewManager.hardReload")(function* (tabId) { | ||
| 1859 | yield* attempt("hardReload", () => manager.hardReload(tabId)); | ||
| 1860 | }), | ||
| 1861 | openDevTools: Effect.fn("PreviewManager.openDevTools")(function* (tabId) { | ||
| 1862 | yield* attempt("openDevTools", () => manager.openDevTools(tabId)); | ||
| 1863 | }), | ||
| 1864 | clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { | ||
| 1865 | yield* browserSessionEffect("clearCookies", browserSession.clearCookies()); | ||
| 1866 | }), | ||
| 1867 | clearCache: Effect.fn("PreviewManager.clearCache")(function* () { | ||
| 1868 | yield* browserSessionEffect("clearCache", browserSession.clearCache()); | ||
| 1869 | }), | ||
| 1870 | getBrowserPartition: browserSession.getPartition, | ||
| 1871 | setAnnotationTheme: Effect.fn("PreviewManager.setAnnotationTheme")(function* (theme) { | ||
| 1872 | yield* attempt("setAnnotationTheme", () => manager.setAnnotationTheme(theme)); | ||
| 1873 | }), | ||
| 1874 | pickElement: Effect.fn("PreviewManager.pickElement")(function* (tabId) { | ||
| 1875 | return yield* attemptPromise("pickElement", () => manager.pickElement(tabId)); | ||
| 1876 | }), | ||
| 1877 | cancelPickElement: Effect.fn("PreviewManager.cancelPickElement")(function* (tabId) { | ||
| 1878 | yield* attempt("cancelPickElement", () => manager.cancelPickElement(tabId)); | ||
| 1879 | }), | ||
| 1880 | captureScreenshot: Effect.fn("PreviewManager.captureScreenshot")(function* (tabId) { | ||
| 1881 | return yield* attemptPromise("captureScreenshot", () => manager.captureScreenshot(tabId)); | ||
| 1882 | }), | ||
| 1883 | revealArtifact: Effect.fn("PreviewManager.revealArtifact")(function* (path) { | ||
| 1884 | yield* attempt("revealArtifact", () => manager.revealArtifact(path)); | ||
| 1885 | }), | ||
| 1886 | copyArtifactToClipboard: Effect.fn("PreviewManager.copyArtifactToClipboard")(function* (path) { | ||
| 1887 | yield* attempt("copyArtifactToClipboard", () => manager.copyArtifactToClipboard(path)); | ||
| 1888 | }), | ||
| 1889 | startRecording: Effect.fn("PreviewManager.startRecording")(function* (tabId) { | ||
| 1890 | yield* attemptPromise("startRecording", () => manager.startRecording(tabId)); | ||
| 1891 | }), | ||
| 1892 | stopRecording: Effect.fn("PreviewManager.stopRecording")(function* (tabId) { | ||
| 1893 | yield* attemptPromise("stopRecording", () => manager.stopRecording(tabId)); | ||
| 1894 | }), | ||
| 1895 | saveRecording: Effect.fn("PreviewManager.saveRecording")(function* (tabId, mimeType, data) { | ||
| 1896 | return yield* attemptPromise("saveRecording", () => | ||
| 1897 | manager.saveRecording(tabId, mimeType, data), | ||
| 1898 | ); | ||
| 1899 | }), | ||
| 1900 | automationStatus: Effect.fn("PreviewManager.automationStatus")(function* (tabId) { | ||
| 1901 | return yield* attempt("automationStatus", () => manager.automationStatus(tabId)); | ||
| 1902 | }), | ||
| 1903 | automationSnapshot: Effect.fn("PreviewManager.automationSnapshot")(function* (tabId) { | ||
| 1904 | return yield* attemptPromise("automationSnapshot", () => manager.automationSnapshot(tabId)); | ||
| 1905 | }), | ||
| 1906 | automationClick: Effect.fn("PreviewManager.automationClick")(function* (tabId, input) { | ||
| 1907 | yield* attemptPromise("automationClick", () => manager.automationClick(tabId, input)); | ||
| 1908 | }), | ||
| 1909 | automationType: Effect.fn("PreviewManager.automationType")(function* (tabId, input) { | ||
| 1910 | yield* attemptPromise("automationType", () => manager.automationType(tabId, input)); | ||
| 1911 | }), | ||
| 1912 | automationPress: Effect.fn("PreviewManager.automationPress")(function* (tabId, input) { | ||
| 1913 | yield* attemptPromise("automationPress", () => manager.automationPress(tabId, input)); | ||
| 1914 | }), | ||
| 1915 | automationScroll: Effect.fn("PreviewManager.automationScroll")(function* (tabId, input) { | ||
| 1916 | yield* attemptPromise("automationScroll", () => manager.automationScroll(tabId, input)); | ||
| 1917 | }), | ||
| 1918 | automationEvaluate: Effect.fn("PreviewManager.automationEvaluate")(function* (tabId, input) { | ||
| 1919 | return yield* attemptPromise("automationEvaluate", () => | ||
| 1920 | manager.automationEvaluate(tabId, input), | ||
| 1921 | ); | ||
| 1922 | }), | ||
| 1923 | automationWaitFor: Effect.fn("PreviewManager.automationWaitFor")(function* (tabId, input) { | ||
| 1924 | yield* attemptPromise("automationWaitFor", () => manager.automationWaitFor(tabId, input)); | ||
| 1925 | }), | ||
| 1926 | subscribeStateChanges: (listener) => | ||
| 1927 | Effect.acquireRelease( | ||
| 1928 | Effect.sync(() => manager.onStateChange(listener)), | ||
| 1929 | (unsubscribe) => Effect.sync(unsubscribe), | ||
| 1930 | ).pipe(Effect.asVoid), | ||
| 1931 | subscribePointerEvents: (listener) => | ||
| 1932 | Effect.acquireRelease( | ||
| 1933 | Effect.sync(() => manager.onPointerEvent(listener)), | ||
| 1934 | (unsubscribe) => Effect.sync(unsubscribe), | ||
| 1935 | ).pipe(Effect.asVoid), | ||
| 1936 | subscribeRecordingFrames: (listener) => | ||
| 1937 | Effect.acquireRelease( | ||
| 1938 | Effect.sync(() => manager.onRecordingFrame(listener)), | ||
| 1939 | (unsubscribe) => Effect.sync(unsubscribe), | ||
| 1940 | ).pipe(Effect.asVoid), | ||
| 1941 | }); | ||
| 1942 | }); | ||
| 1943 | |||
| 1944 | export const layer = Layer.effect(PreviewManager, make()); | ||
| 1945 | |||
| 1946 | /** Exposed for tests. */ | ||
| 1947 | export const __testing = { | ||
| 1948 | PreviewViewManager, | ||
| 1949 | }; | ||
apps/desktop/src/preview/PickLabelPosition.ts created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | /** | ||
| 2 | * Pure clamp/flip math for the floating label that follows the cursor while | ||
| 3 | * the user is picking an element in the in-app browser. Lives in its own | ||
| 4 | * electron-free module so the geometry can be unit-tested without spinning | ||
| 5 | * up an Electron preload context (`PickPreload.ts` itself imports | ||
| 6 | * `electron` and `react-grab/primitives`, which can't load under vitest). | ||
| 7 | * | ||
| 8 | * - Horizontally pins the label to `targetLeft`, clamped into | ||
| 9 | * `[VIEWPORT_MARGIN, viewportWidth - labelWidth - VIEWPORT_MARGIN]`. | ||
| 10 | * - Vertically prefers above the target. If the label would overflow the | ||
| 11 | * top, flips below; if THAT also overflows the bottom, pins to the | ||
| 12 | * bottom margin (better to overlap the highlight than disappear). | ||
| 13 | */ | ||
| 14 | |||
| 15 | /** Distance in CSS pixels between the highlight and the floating label. */ | ||
| 16 | export const LABEL_GAP = 4; | ||
| 17 | /** Minimum padding the label keeps from any viewport edge. */ | ||
| 18 | export const VIEWPORT_MARGIN = 4; | ||
| 19 | |||
| 20 | export function computeLabelPosition(input: { | ||
| 21 | targetLeft: number; | ||
| 22 | targetTop: number; | ||
| 23 | targetBottom: number; | ||
| 24 | labelWidth: number; | ||
| 25 | labelHeight: number; | ||
| 26 | viewportWidth: number; | ||
| 27 | viewportHeight: number; | ||
| 28 | }): { x: number; y: number } { | ||
| 29 | const { targetLeft, targetTop, targetBottom, labelWidth, labelHeight } = input; | ||
| 30 | const { viewportWidth, viewportHeight } = input; | ||
| 31 | |||
| 32 | let x = targetLeft; | ||
| 33 | const maxX = viewportWidth - labelWidth - VIEWPORT_MARGIN; | ||
| 34 | if (x > maxX) x = maxX; | ||
| 35 | if (x < VIEWPORT_MARGIN) x = VIEWPORT_MARGIN; | ||
| 36 | |||
| 37 | let y = targetTop - labelHeight - LABEL_GAP; | ||
| 38 | if (y < VIEWPORT_MARGIN) { | ||
| 39 | y = targetBottom + LABEL_GAP; | ||
| 40 | if (y + labelHeight > viewportHeight - VIEWPORT_MARGIN) { | ||
| 41 | y = Math.max(VIEWPORT_MARGIN, viewportHeight - labelHeight - VIEWPORT_MARGIN); | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | return { x, y }; | ||
| 46 | } | ||
apps/desktop/src/preview/PickPreload.test.ts created+86| ... | @@ -0,0 +1,86 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { computeLabelPosition } from "./PickLabelPosition.ts"; | ||
| 4 | |||
| 5 | const VIEWPORT = { viewportWidth: 1280, viewportHeight: 800 }; | ||
| 6 | |||
| 7 | describe("computeLabelPosition", () => { | ||
| 8 | it("anchors to the element's top-left when there's room above and to the right", () => { | ||
| 9 | const { x, y } = computeLabelPosition({ | ||
| 10 | ...VIEWPORT, | ||
| 11 | targetLeft: 200, | ||
| 12 | targetTop: 200, | ||
| 13 | targetBottom: 240, | ||
| 14 | labelWidth: 120, | ||
| 15 | labelHeight: 18, | ||
| 16 | }); | ||
| 17 | expect(x).toBe(200); | ||
| 18 | // 200 (top) - 18 (height) - 4 (gap) | ||
| 19 | expect(y).toBe(200 - 18 - 4); | ||
| 20 | }); | ||
| 21 | |||
| 22 | it("clamps left edge so the label stays inside the viewport", () => { | ||
| 23 | const { x } = computeLabelPosition({ | ||
| 24 | ...VIEWPORT, | ||
| 25 | targetLeft: -50, | ||
| 26 | targetTop: 200, | ||
| 27 | targetBottom: 240, | ||
| 28 | labelWidth: 120, | ||
| 29 | labelHeight: 18, | ||
| 30 | }); | ||
| 31 | expect(x).toBe(4); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("clamps right edge when the label would overflow the viewport (the bug we shipped)", () => { | ||
| 35 | const { x } = computeLabelPosition({ | ||
| 36 | ...VIEWPORT, | ||
| 37 | targetLeft: 1240, | ||
| 38 | targetTop: 200, | ||
| 39 | targetBottom: 240, | ||
| 40 | labelWidth: 200, | ||
| 41 | labelHeight: 18, | ||
| 42 | }); | ||
| 43 | // viewportWidth (1280) - labelWidth (200) - margin (4) = 1076 | ||
| 44 | expect(x).toBe(1076); | ||
| 45 | }); | ||
| 46 | |||
| 47 | it("flips the label below the element when there's no room above", () => { | ||
| 48 | const { y } = computeLabelPosition({ | ||
| 49 | ...VIEWPORT, | ||
| 50 | targetLeft: 200, | ||
| 51 | targetTop: 4, | ||
| 52 | targetBottom: 44, | ||
| 53 | labelWidth: 120, | ||
| 54 | labelHeight: 18, | ||
| 55 | }); | ||
| 56 | // labelY = 4 - 18 - 4 = -18 → flip → 44 + 4 = 48 | ||
| 57 | expect(y).toBe(48); | ||
| 58 | }); | ||
| 59 | |||
| 60 | it("pins to the bottom margin when the element fills the viewport (no room above OR below)", () => { | ||
| 61 | const { y } = computeLabelPosition({ | ||
| 62 | ...VIEWPORT, | ||
| 63 | targetLeft: 200, | ||
| 64 | targetTop: 0, | ||
| 65 | targetBottom: 800, | ||
| 66 | labelWidth: 120, | ||
| 67 | labelHeight: 18, | ||
| 68 | }); | ||
| 69 | // Above overflows top → flip below = 800 + 4 = 804 → also overflows | ||
| 70 | // bottom → pin to viewportHeight - labelHeight - margin = 778. | ||
| 71 | expect(y).toBe(800 - 18 - 4); | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("never returns a negative coordinate", () => { | ||
| 75 | const { x, y } = computeLabelPosition({ | ||
| 76 | ...VIEWPORT, | ||
| 77 | targetLeft: -1000, | ||
| 78 | targetTop: -1000, | ||
| 79 | targetBottom: -900, | ||
| 80 | labelWidth: 5000, | ||
| 81 | labelHeight: 5000, | ||
| 82 | }); | ||
| 83 | expect(x).toBeGreaterThanOrEqual(0); | ||
| 84 | expect(y).toBeGreaterThanOrEqual(0); | ||
| 85 | }); | ||
| 86 | }); | ||
apps/desktop/src/preview/PickPreload.ts created+1263| ... | @@ -0,0 +1,1263 @@ | ||
| 1 | // @effect-diagnostics globalDate:off | ||
| 2 | import { ipcRenderer } from "electron"; | ||
| 3 | import { getElementContext } from "react-grab/primitives"; | ||
| 4 | import type { | ||
| 5 | DesktopPreviewAnnotationTheme, | ||
| 6 | PickedElementPayload, | ||
| 7 | PickedElementStackFrame, | ||
| 8 | PreviewAnnotationPayload, | ||
| 9 | PreviewAnnotationPoint, | ||
| 10 | PreviewAnnotationRect, | ||
| 11 | PreviewAnnotationRegionTarget, | ||
| 12 | PreviewAnnotationStrokeTarget, | ||
| 13 | PreviewAnnotationStyleChange, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | |||
| 16 | import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; | ||
| 17 | import { | ||
| 18 | ANNOTATION_CAPTURED_CHANNEL, | ||
| 19 | ANNOTATION_THEME_CHANNEL, | ||
| 20 | CANCEL_PICK_CHANNEL, | ||
| 21 | ELEMENT_PICKED_CHANNEL, | ||
| 22 | HUMAN_INPUT_CHANNEL, | ||
| 23 | START_PICK_CHANNEL, | ||
| 24 | } from "./GuestProtocol.ts"; | ||
| 25 | const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; | ||
| 26 | const Z_INDEX_OVERLAY = 2147483646; | ||
| 27 | const PRIMARY = "var(--t3-primary)"; | ||
| 28 | const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; | ||
| 29 | const MAX_MARQUEE_ELEMENTS = 20; | ||
| 30 | const CONTENT_LAYER_Z_INDEX = 1; | ||
| 31 | const CHROME_LAYER_Z_INDEX = 10; | ||
| 32 | |||
| 33 | type AnnotationTool = "select" | "marquee" | "draw" | "erase"; | ||
| 34 | |||
| 35 | interface SelectedElement { | ||
| 36 | id: string; | ||
| 37 | element: Element; | ||
| 38 | outline: HTMLDivElement; | ||
| 39 | label: HTMLDivElement; | ||
| 40 | baselineStyles: Map<string, string>; | ||
| 41 | } | ||
| 42 | |||
| 43 | interface AnnotationSession { | ||
| 44 | teardown: (notifyMain: boolean) => void; | ||
| 45 | applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; | ||
| 46 | } | ||
| 47 | |||
| 48 | let activeSession: AnnotationSession | null = null; | ||
| 49 | let idSequence = 0; | ||
| 50 | let annotationTheme: DesktopPreviewAnnotationTheme | null = null; | ||
| 51 | |||
| 52 | const applyAnnotationTheme = ( | ||
| 53 | host: HTMLElement, | ||
| 54 | theme: DesktopPreviewAnnotationTheme | null, | ||
| 55 | ): void => { | ||
| 56 | if (!theme) return; | ||
| 57 | host.style.colorScheme = theme.colorScheme; | ||
| 58 | const variables = { | ||
| 59 | "--t3-radius": theme.radius, | ||
| 60 | "--t3-background": theme.background, | ||
| 61 | "--t3-foreground": theme.foreground, | ||
| 62 | "--t3-popover": theme.popover, | ||
| 63 | "--t3-popover-foreground": theme.popoverForeground, | ||
| 64 | "--t3-primary": theme.primary, | ||
| 65 | "--t3-primary-foreground": theme.primaryForeground, | ||
| 66 | "--t3-muted": theme.muted, | ||
| 67 | "--t3-muted-foreground": theme.mutedForeground, | ||
| 68 | "--t3-accent": theme.accent, | ||
| 69 | "--t3-accent-foreground": theme.accentForeground, | ||
| 70 | "--t3-border": theme.border, | ||
| 71 | "--t3-input": theme.input, | ||
| 72 | "--t3-ring": theme.ring, | ||
| 73 | "--t3-font-sans": theme.fontSans, | ||
| 74 | "--t3-font-mono": theme.fontMono, | ||
| 75 | }; | ||
| 76 | for (const [name, value] of Object.entries(variables)) { | ||
| 77 | host.style.setProperty(name, value); | ||
| 78 | } | ||
| 79 | }; | ||
| 80 | |||
| 81 | const reportHumanPointerInput = (event: PointerEvent): void => { | ||
| 82 | if (!event.isTrusted) return; | ||
| 83 | ipcRenderer.send(HUMAN_INPUT_CHANNEL, { | ||
| 84 | kind: "pointer", | ||
| 85 | x: event.clientX, | ||
| 86 | y: event.clientY, | ||
| 87 | button: event.button, | ||
| 88 | }); | ||
| 89 | }; | ||
| 90 | |||
| 91 | const reportHumanKeyInput = (event: KeyboardEvent): void => { | ||
| 92 | if (!event.isTrusted) return; | ||
| 93 | ipcRenderer.send(HUMAN_INPUT_CHANNEL, { | ||
| 94 | kind: "key", | ||
| 95 | key: event.key, | ||
| 96 | code: event.code, | ||
| 97 | }); | ||
| 98 | }; | ||
| 99 | |||
| 100 | window.addEventListener("pointerdown", reportHumanPointerInput, true); | ||
| 101 | window.addEventListener("keydown", reportHumanKeyInput, true); | ||
| 102 | |||
| 103 | const nextId = (prefix: string): string => { | ||
| 104 | idSequence += 1; | ||
| 105 | return `${prefix}_${idSequence.toString(36)}`; | ||
| 106 | }; | ||
| 107 | |||
| 108 | const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({ | ||
| 109 | x: rect.left, | ||
| 110 | y: rect.top, | ||
| 111 | width: rect.width, | ||
| 112 | height: rect.height, | ||
| 113 | }); | ||
| 114 | |||
| 115 | const normalizeRect = ( | ||
| 116 | startX: number, | ||
| 117 | startY: number, | ||
| 118 | endX: number, | ||
| 119 | endY: number, | ||
| 120 | ): PreviewAnnotationRect => ({ | ||
| 121 | x: Math.min(startX, endX), | ||
| 122 | y: Math.min(startY, endY), | ||
| 123 | width: Math.abs(endX - startX), | ||
| 124 | height: Math.abs(endY - startY), | ||
| 125 | }); | ||
| 126 | |||
| 127 | const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; | ||
| 128 | |||
| 129 | function unionRects( | ||
| 130 | rects: ReadonlyArray<PreviewAnnotationRect>, | ||
| 131 | padding = 20, | ||
| 132 | ): PreviewAnnotationRect | null { | ||
| 133 | if (rects.length === 0) return null; | ||
| 134 | const left = Math.min(...rects.map((rect) => rect.x)); | ||
| 135 | const top = Math.min(...rects.map((rect) => rect.y)); | ||
| 136 | const right = Math.max(...rects.map((rect) => rect.x + rect.width)); | ||
| 137 | const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); | ||
| 138 | const x = Math.max(0, left - padding); | ||
| 139 | const y = Math.max(0, top - padding); | ||
| 140 | const maxWidth = Math.max(1, window.innerWidth - x); | ||
| 141 | const maxHeight = Math.max(1, window.innerHeight - y); | ||
| 142 | return { | ||
| 143 | x, | ||
| 144 | y, | ||
| 145 | width: Math.min(maxWidth, right - left + padding * 2), | ||
| 146 | height: Math.min(maxHeight, bottom - top + padding * 2), | ||
| 147 | }; | ||
| 148 | } | ||
| 149 | |||
| 150 | function isAnnotationNode(element: Element): boolean { | ||
| 151 | return element instanceof Element && element.closest(`[${OVERLAY_ATTRIBUTE}]`) !== null; | ||
| 152 | } | ||
| 153 | |||
| 154 | function pickFromPoint(clientX: number, clientY: number): Element | null { | ||
| 155 | for (const candidate of document.elementsFromPoint(clientX, clientY)) { | ||
| 156 | if (!(candidate instanceof Element)) continue; | ||
| 157 | if (isAnnotationNode(candidate)) continue; | ||
| 158 | if (candidate === document.documentElement || candidate === document.body) continue; | ||
| 159 | return candidate; | ||
| 160 | } | ||
| 161 | return null; | ||
| 162 | } | ||
| 163 | |||
| 164 | function describeRawElement(element: Element): string { | ||
| 165 | const tag = element.tagName.toLowerCase(); | ||
| 166 | const id = element.id ? `#${element.id}` : ""; | ||
| 167 | const classes = | ||
| 168 | element instanceof HTMLElement && typeof element.className === "string" | ||
| 169 | ? element.className | ||
| 170 | .trim() | ||
| 171 | .split(/\s+/) | ||
| 172 | .filter(Boolean) | ||
| 173 | .slice(0, 2) | ||
| 174 | .map((name) => `.${name}`) | ||
| 175 | .join("") | ||
| 176 | : ""; | ||
| 177 | return `${tag}${id}${classes}`; | ||
| 178 | } | ||
| 179 | |||
| 180 | function createBox(color: string, fill: string): HTMLDivElement { | ||
| 181 | const node = document.createElement("div"); | ||
| 182 | node.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 183 | node.style.cssText = [ | ||
| 184 | "position:fixed", | ||
| 185 | "pointer-events:none", | ||
| 186 | `border:2px solid ${color}`, | ||
| 187 | `background:${fill}`, | ||
| 188 | "border-radius:3px", | ||
| 189 | "box-sizing:border-box", | ||
| 190 | "display:none", | ||
| 191 | `z-index:${CONTENT_LAYER_Z_INDEX}`, | ||
| 192 | ].join(";"); | ||
| 193 | return node; | ||
| 194 | } | ||
| 195 | |||
| 196 | function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { | ||
| 197 | node.style.display = "block"; | ||
| 198 | node.style.transform = `translate(${rect.x}px, ${rect.y}px)`; | ||
| 199 | node.style.width = `${rect.width}px`; | ||
| 200 | node.style.height = `${rect.height}px`; | ||
| 201 | } | ||
| 202 | |||
| 203 | function createLabel(): HTMLDivElement { | ||
| 204 | const label = document.createElement("div"); | ||
| 205 | label.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 206 | label.className = | ||
| 207 | "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"; | ||
| 208 | label.style.cssText = [ | ||
| 209 | "position:fixed", | ||
| 210 | "pointer-events:none", | ||
| 211 | "white-space:nowrap", | ||
| 212 | "text-overflow:ellipsis", | ||
| 213 | `z-index:${CONTENT_LAYER_Z_INDEX}`, | ||
| 214 | ].join(";"); | ||
| 215 | return label; | ||
| 216 | } | ||
| 217 | |||
| 218 | function updateSelectedVisual(target: SelectedElement): void { | ||
| 219 | if (!target.element.isConnected) { | ||
| 220 | target.outline.style.display = "none"; | ||
| 221 | target.label.style.display = "none"; | ||
| 222 | return; | ||
| 223 | } | ||
| 224 | const rect = target.element.getBoundingClientRect(); | ||
| 225 | positionBox(target.outline, rectFromDomRect(rect)); | ||
| 226 | target.label.textContent = describeRawElement(target.element); | ||
| 227 | target.label.style.display = "block"; | ||
| 228 | target.label.style.transform = `translate(${Math.max(4, rect.left)}px, ${Math.max(4, rect.top - 22)}px)`; | ||
| 229 | } | ||
| 230 | |||
| 231 | function toStackFrame(frame: { | ||
| 232 | functionName?: string; | ||
| 233 | fileName?: string; | ||
| 234 | lineNumber?: number; | ||
| 235 | columnNumber?: number; | ||
| 236 | }): PickedElementStackFrame { | ||
| 237 | return { | ||
| 238 | functionName: frame.functionName ?? null, | ||
| 239 | fileName: frame.fileName ?? null, | ||
| 240 | lineNumber: frame.lineNumber ?? null, | ||
| 241 | columnNumber: frame.columnNumber ?? null, | ||
| 242 | }; | ||
| 243 | } | ||
| 244 | |||
| 245 | async function captureElement(element: Element): Promise<PickedElementPayload | null> { | ||
| 246 | try { | ||
| 247 | const context = await getElementContext(element); | ||
| 248 | const stack = (context.stack ?? []).map(toStackFrame); | ||
| 249 | return { | ||
| 250 | pageUrl: location.href, | ||
| 251 | pageTitle: document.title?.trim() || null, | ||
| 252 | tagName: element.tagName.toLowerCase(), | ||
| 253 | selector: context.selector, | ||
| 254 | htmlPreview: context.htmlPreview ?? "", | ||
| 255 | componentName: context.componentName, | ||
| 256 | source: stack[0] ?? null, | ||
| 257 | stack, | ||
| 258 | styles: context.styles ?? "", | ||
| 259 | pickedAt: new Date().toISOString(), | ||
| 260 | }; | ||
| 261 | } catch { | ||
| 262 | return null; | ||
| 263 | } | ||
| 264 | } | ||
| 265 | |||
| 266 | function createButton(label: string, title: string): HTMLButtonElement { | ||
| 267 | const button = document.createElement("button"); | ||
| 268 | button.type = "button"; | ||
| 269 | button.textContent = label; | ||
| 270 | button.title = title; | ||
| 271 | button.className = | ||
| 272 | "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"; | ||
| 273 | return button; | ||
| 274 | } | ||
| 275 | |||
| 276 | function styleControl(input: HTMLInputElement | HTMLSelectElement): void { | ||
| 277 | input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value"); | ||
| 278 | input.className = | ||
| 279 | "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"; | ||
| 280 | } | ||
| 281 | |||
| 282 | function createUnitControl(input: HTMLInputElement): HTMLElement { | ||
| 283 | const wrapper = document.createElement("div"); | ||
| 284 | wrapper.style.cssText = "position:relative;min-width:0"; | ||
| 285 | const unit = document.createElement("span"); | ||
| 286 | unit.textContent = input.dataset.unit ?? ""; | ||
| 287 | unit.className = | ||
| 288 | "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground"; | ||
| 289 | wrapper.append(input, unit); | ||
| 290 | return wrapper; | ||
| 291 | } | ||
| 292 | |||
| 293 | function createField( | ||
| 294 | labelText: string, | ||
| 295 | input: HTMLInputElement | HTMLSelectElement, | ||
| 296 | ): HTMLLabelElement { | ||
| 297 | const label = document.createElement("label"); | ||
| 298 | label.className = | ||
| 299 | "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; | ||
| 300 | const text = document.createElement("span"); | ||
| 301 | text.textContent = labelText; | ||
| 302 | styleControl(input); | ||
| 303 | label.append( | ||
| 304 | text, | ||
| 305 | input instanceof HTMLInputElement && input.dataset.unit ? createUnitControl(input) : input, | ||
| 306 | ); | ||
| 307 | return label; | ||
| 308 | } | ||
| 309 | |||
| 310 | function createStyleSection(): HTMLElement { | ||
| 311 | const section = document.createElement("section"); | ||
| 312 | section.className = "grid gap-1 border-t border-border py-2"; | ||
| 313 | return section; | ||
| 314 | } | ||
| 315 | |||
| 316 | function createUnitInput(unit: string, placeholder = "0"): HTMLInputElement { | ||
| 317 | const input = document.createElement("input"); | ||
| 318 | input.type = "number"; | ||
| 319 | input.placeholder = placeholder; | ||
| 320 | input.style.paddingRight = "30px"; | ||
| 321 | input.dataset.unit = unit; | ||
| 322 | return input; | ||
| 323 | } | ||
| 324 | |||
| 325 | function pathFromPoints(points: ReadonlyArray<PreviewAnnotationPoint>): string { | ||
| 326 | if (points.length === 0) return ""; | ||
| 327 | if (points.length === 1) return `M ${points[0]!.x} ${points[0]!.y} l 0.01 0.01`; | ||
| 328 | let path = `M ${points[0]!.x} ${points[0]!.y}`; | ||
| 329 | for (let index = 1; index < points.length - 1; index += 1) { | ||
| 330 | const current = points[index]!; | ||
| 331 | const next = points[index + 1]!; | ||
| 332 | path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`; | ||
| 333 | } | ||
| 334 | const last = points[points.length - 1]!; | ||
| 335 | path += ` L ${last.x} ${last.y}`; | ||
| 336 | return path; | ||
| 337 | } | ||
| 338 | |||
| 339 | function strokeBounds( | ||
| 340 | points: ReadonlyArray<PreviewAnnotationPoint>, | ||
| 341 | width: number, | ||
| 342 | ): PreviewAnnotationRect { | ||
| 343 | const xs = points.map((point) => point.x); | ||
| 344 | const ys = points.map((point) => point.y); | ||
| 345 | const padding = width + 3; | ||
| 346 | const left = Math.min(...xs) - padding; | ||
| 347 | const top = Math.min(...ys) - padding; | ||
| 348 | const right = Math.max(...xs) + padding; | ||
| 349 | const bottom = Math.max(...ys) + padding; | ||
| 350 | return { x: left, y: top, width: right - left, height: bottom - top }; | ||
| 351 | } | ||
| 352 | |||
| 353 | function startAnnotation(): void { | ||
| 354 | activeSession?.teardown(false); | ||
| 355 | let finished = false; | ||
| 356 | const host = document.createElement("div"); | ||
| 357 | host.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 358 | host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`; | ||
| 359 | applyAnnotationTheme(host, annotationTheme); | ||
| 360 | const shadowRoot = host.attachShadow({ mode: "closed" }); | ||
| 361 | const themeStyle = document.createElement("style"); | ||
| 362 | themeStyle.textContent = previewAnnotationStyles; | ||
| 363 | shadowRoot.appendChild(themeStyle); | ||
| 364 | |||
| 365 | const root = document.createElement("div"); | ||
| 366 | root.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 367 | root.className = "fixed inset-0 font-sans text-foreground"; | ||
| 368 | root.style.cssText = "pointer-events:none"; | ||
| 369 | const cursorStyle = document.createElement("style"); | ||
| 370 | cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 371 | 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; }`; | ||
| 372 | document.documentElement.appendChild(cursorStyle); | ||
| 373 | shadowRoot.appendChild(root); | ||
| 374 | |||
| 375 | const hoverOutline = createBox(PRIMARY, PRIMARY_FILL); | ||
| 376 | const marqueeBox = createBox(PRIMARY, PRIMARY_FILL); | ||
| 377 | root.append(hoverOutline, marqueeBox); | ||
| 378 | |||
| 379 | const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); | ||
| 380 | svg.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 381 | svg.setAttribute("width", "100%"); | ||
| 382 | svg.setAttribute("height", "100%"); | ||
| 383 | svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); | ||
| 384 | svg.style.cssText = "position:fixed;inset:0;overflow:visible;pointer-events:none"; | ||
| 385 | svg.style.zIndex = String(CONTENT_LAYER_Z_INDEX); | ||
| 386 | root.appendChild(svg); | ||
| 387 | |||
| 388 | const toolbar = document.createElement("div"); | ||
| 389 | toolbar.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 390 | toolbar.className = | ||
| 391 | "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"; | ||
| 392 | toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX); | ||
| 393 | root.appendChild(toolbar); | ||
| 394 | |||
| 395 | const editor = document.createElement("div"); | ||
| 396 | editor.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 397 | editor.className = | ||
| 398 | "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"; | ||
| 399 | editor.style.zIndex = String(CHROME_LAYER_Z_INDEX); | ||
| 400 | root.appendChild(editor); | ||
| 401 | |||
| 402 | const composerRow = document.createElement("div"); | ||
| 403 | composerRow.className = "flex items-start gap-2 p-2"; | ||
| 404 | |||
| 405 | const adjust = createButton("", "Expand annotation editor"); | ||
| 406 | adjust.setAttribute("aria-label", "Expand annotation editor"); | ||
| 407 | adjust.setAttribute("aria-expanded", "false"); | ||
| 408 | adjust.className += | ||
| 409 | " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"; | ||
| 410 | adjust.innerHTML = | ||
| 411 | '<svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true"><path d="M4 5h12M4 10h12M4 15h12M7 3v4M13 8v4M9 13v4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>'; | ||
| 412 | composerRow.appendChild(adjust); | ||
| 413 | |||
| 414 | const comment = document.createElement("textarea"); | ||
| 415 | comment.placeholder = "Describe the change…"; | ||
| 416 | comment.rows = 1; | ||
| 417 | comment.className = | ||
| 418 | "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"; | ||
| 419 | composerRow.appendChild(comment); | ||
| 420 | |||
| 421 | const dragHandle = document.createElement("button"); | ||
| 422 | dragHandle.type = "button"; | ||
| 423 | dragHandle.textContent = "â ¿"; | ||
| 424 | dragHandle.title = "Drag annotation editor"; | ||
| 425 | dragHandle.className = | ||
| 426 | "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"; | ||
| 427 | composerRow.appendChild(dragHandle); | ||
| 428 | |||
| 429 | const submit = createButton("Attach", "Attach annotation and screenshot"); | ||
| 430 | submit.className += | ||
| 431 | " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; | ||
| 432 | composerRow.appendChild(submit); | ||
| 433 | editor.appendChild(composerRow); | ||
| 434 | |||
| 435 | const stylePanel = document.createElement("div"); | ||
| 436 | stylePanel.className = | ||
| 437 | "hidden max-h-[min(176px,calc(100vh-180px))] overflow-auto border-t border-border bg-muted/40 px-3"; | ||
| 438 | editor.appendChild(stylePanel); | ||
| 439 | |||
| 440 | const selected = new Map<Element, SelectedElement>(); | ||
| 441 | const regions: PreviewAnnotationRegionTarget[] = []; | ||
| 442 | const strokes: PreviewAnnotationStrokeTarget[] = []; | ||
| 443 | const styleChanges = new Map<string, PreviewAnnotationStyleChange>(); | ||
| 444 | const toolButtons = new Map<AnnotationTool, HTMLButtonElement>(); | ||
| 445 | let tool: AnnotationTool = "select"; | ||
| 446 | let dragStart: PreviewAnnotationPoint | null = null; | ||
| 447 | let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; | ||
| 448 | let pendingCapture = false; | ||
| 449 | let editorExpanded = false; | ||
| 450 | let editorWasShown = false; | ||
| 451 | let editorPosition: { left: number; top: number } | null = null; | ||
| 452 | let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; | ||
| 453 | let editorLayoutFrame: number | null = null; | ||
| 454 | |||
| 455 | const resizeComment = (): void => { | ||
| 456 | const maxHeight = 96; | ||
| 457 | comment.style.height = "auto"; | ||
| 458 | const nextHeight = Math.min(comment.scrollHeight, maxHeight); | ||
| 459 | comment.style.height = `${nextHeight}px`; | ||
| 460 | comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden"; | ||
| 461 | queueEditorLayout(); | ||
| 462 | }; | ||
| 463 | comment.addEventListener("input", resizeComment); | ||
| 464 | |||
| 465 | const updateStatus = (): void => { | ||
| 466 | const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0; | ||
| 467 | editor.style.display = hasTargets ? "flex" : "none"; | ||
| 468 | submit.disabled = !hasTargets; | ||
| 469 | submit.style.opacity = hasTargets ? "1" : "0.45"; | ||
| 470 | adjust.disabled = !hasTargets; | ||
| 471 | stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none"; | ||
| 472 | queueEditorLayout(); | ||
| 473 | if (hasTargets && !editorWasShown) { | ||
| 474 | editorWasShown = true; | ||
| 475 | window.setTimeout(() => comment.focus({ preventScroll: true }), 0); | ||
| 476 | } | ||
| 477 | }; | ||
| 478 | |||
| 479 | const refreshToolButtons = (): void => { | ||
| 480 | for (const [candidate, button] of toolButtons) { | ||
| 481 | const active = candidate === tool; | ||
| 482 | button.classList.toggle("bg-primary/10", active); | ||
| 483 | button.classList.toggle("text-primary", active); | ||
| 484 | button.classList.toggle("text-foreground", !active); | ||
| 485 | } | ||
| 486 | if (tool !== "select") hoverOutline.style.display = "none"; | ||
| 487 | if (tool !== "marquee") marqueeBox.style.display = "none"; | ||
| 488 | document.documentElement.setAttribute("data-t3code-annotation-tool", tool); | ||
| 489 | }; | ||
| 490 | |||
| 491 | const removeSelected = (target: SelectedElement): void => { | ||
| 492 | if (target.element instanceof HTMLElement || target.element instanceof SVGElement) { | ||
| 493 | for (const [property, baseline] of target.baselineStyles) { | ||
| 494 | if (baseline) target.element.style.setProperty(property, baseline); | ||
| 495 | else target.element.style.removeProperty(property); | ||
| 496 | } | ||
| 497 | } | ||
| 498 | selected.delete(target.element); | ||
| 499 | target.outline.remove(); | ||
| 500 | target.label.remove(); | ||
| 501 | for (const [key, change] of styleChanges) { | ||
| 502 | if (change.targetId === target.id) styleChanges.delete(key); | ||
| 503 | } | ||
| 504 | updateStatus(); | ||
| 505 | }; | ||
| 506 | |||
| 507 | const addSelected = (element: Element): void => { | ||
| 508 | if (selected.has(element)) return; | ||
| 509 | const target: SelectedElement = { | ||
| 510 | id: nextId("element"), | ||
| 511 | element, | ||
| 512 | outline: createBox(PRIMARY, PRIMARY_FILL), | ||
| 513 | label: createLabel(), | ||
| 514 | baselineStyles: new Map(), | ||
| 515 | }; | ||
| 516 | selected.set(element, target); | ||
| 517 | root.append(target.outline, target.label); | ||
| 518 | updateSelectedVisual(target); | ||
| 519 | updateStatus(); | ||
| 520 | if (editorExpanded) { | ||
| 521 | stylePanel.style.display = "grid"; | ||
| 522 | syncStyleControls(); | ||
| 523 | } | ||
| 524 | }; | ||
| 525 | |||
| 526 | const toggleSelected = (element: Element, additive: boolean): void => { | ||
| 527 | const existing = selected.get(element); | ||
| 528 | if (existing) { | ||
| 529 | removeSelected(existing); | ||
| 530 | return; | ||
| 531 | } | ||
| 532 | if (!additive) { | ||
| 533 | for (const target of Array.from(selected.values())) removeSelected(target); | ||
| 534 | } | ||
| 535 | addSelected(element); | ||
| 536 | }; | ||
| 537 | |||
| 538 | const setStyleForSelected = (property: string, value: string): void => { | ||
| 539 | for (const target of selected.values()) { | ||
| 540 | if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) | ||
| 541 | continue; | ||
| 542 | if (!target.baselineStyles.has(property)) { | ||
| 543 | target.baselineStyles.set(property, target.element.style.getPropertyValue(property)); | ||
| 544 | } | ||
| 545 | const key = `${target.id}:${property}`; | ||
| 546 | const previousValue = | ||
| 547 | styleChanges.get(key)?.previousValue ?? | ||
| 548 | getComputedStyle(target.element).getPropertyValue(property).trim(); | ||
| 549 | target.element.style.setProperty(property, value, "important"); | ||
| 550 | styleChanges.set(key, { | ||
| 551 | targetId: target.id, | ||
| 552 | selector: null, | ||
| 553 | property, | ||
| 554 | previousValue, | ||
| 555 | value, | ||
| 556 | }); | ||
| 557 | updateSelectedVisual(target); | ||
| 558 | } | ||
| 559 | }; | ||
| 560 | |||
| 561 | const textSection = createStyleSection(); | ||
| 562 | const colorsSection = createStyleSection(); | ||
| 563 | const bordersSection = createStyleSection(); | ||
| 564 | const sizingSection = createStyleSection(); | ||
| 565 | stylePanel.append(textSection, colorsSection, bordersSection, sizingSection); | ||
| 566 | |||
| 567 | const fontFamily = document.createElement("select"); | ||
| 568 | for (const value of ["inherit", "system-ui", "sans-serif", "serif", "monospace"]) { | ||
| 569 | const option = document.createElement("option"); | ||
| 570 | option.value = value; | ||
| 571 | option.textContent = value; | ||
| 572 | fontFamily.appendChild(option); | ||
| 573 | } | ||
| 574 | fontFamily.addEventListener("change", () => setStyleForSelected("font-family", fontFamily.value)); | ||
| 575 | textSection.appendChild(createField("Font", fontFamily)); | ||
| 576 | |||
| 577 | const fontSize = createUnitInput("px", "16"); | ||
| 578 | fontSize.min = "1"; | ||
| 579 | fontSize.max = "300"; | ||
| 580 | fontSize.addEventListener("input", () => { | ||
| 581 | if (fontSize.value) setStyleForSelected("font-size", `${fontSize.value}px`); | ||
| 582 | }); | ||
| 583 | textSection.appendChild(createField("Font size", fontSize)); | ||
| 584 | |||
| 585 | const fontWeight = document.createElement("select"); | ||
| 586 | for (const value of ["300", "400", "500", "600", "700", "800", "900"]) { | ||
| 587 | const option = document.createElement("option"); | ||
| 588 | option.value = value; | ||
| 589 | option.textContent = value; | ||
| 590 | fontWeight.appendChild(option); | ||
| 591 | } | ||
| 592 | fontWeight.addEventListener("change", () => setStyleForSelected("font-weight", fontWeight.value)); | ||
| 593 | textSection.appendChild(createField("Font weight", fontWeight)); | ||
| 594 | |||
| 595 | const lineHeight = document.createElement("input"); | ||
| 596 | lineHeight.type = "text"; | ||
| 597 | lineHeight.placeholder = "normal / 1.4"; | ||
| 598 | lineHeight.addEventListener("change", () => { | ||
| 599 | if (lineHeight.value.trim()) setStyleForSelected("line-height", lineHeight.value.trim()); | ||
| 600 | }); | ||
| 601 | textSection.appendChild(createField("Line height", lineHeight)); | ||
| 602 | |||
| 603 | const createColorRow = ( | ||
| 604 | labelText: string, | ||
| 605 | property: string, | ||
| 606 | section: HTMLElement, | ||
| 607 | ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => { | ||
| 608 | const row = document.createElement("label"); | ||
| 609 | row.className = | ||
| 610 | "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; | ||
| 611 | const label = document.createElement("span"); | ||
| 612 | label.textContent = labelText; | ||
| 613 | const control = document.createElement("div"); | ||
| 614 | control.className = | ||
| 615 | "grid h-7 grid-cols-[22px_minmax(0,1fr)] items-center gap-1 rounded-md border border-input bg-background px-1 shadow-xs"; | ||
| 616 | const color = document.createElement("input"); | ||
| 617 | color.type = "color"; | ||
| 618 | color.setAttribute("aria-label", labelText); | ||
| 619 | color.style.cssText = | ||
| 620 | "width:20px;height:20px;padding:0;border:0;border-radius:5px;overflow:hidden;background:transparent;cursor:pointer"; | ||
| 621 | const text = document.createElement("input"); | ||
| 622 | text.type = "text"; | ||
| 623 | text.setAttribute("aria-label", `${labelText} value`); | ||
| 624 | text.className = | ||
| 625 | "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none"; | ||
| 626 | color.addEventListener("input", () => { | ||
| 627 | text.value = color.value; | ||
| 628 | setStyleForSelected(property, color.value); | ||
| 629 | }); | ||
| 630 | text.addEventListener("change", () => { | ||
| 631 | const value = text.value.trim(); | ||
| 632 | if (!value) return; | ||
| 633 | setStyleForSelected(property, value); | ||
| 634 | if (/^#[0-9a-f]{6}$/i.test(value)) color.value = value; | ||
| 635 | }); | ||
| 636 | control.append(color, text); | ||
| 637 | row.append(label, control); | ||
| 638 | section.appendChild(row); | ||
| 639 | return { row, color, text }; | ||
| 640 | }; | ||
| 641 | |||
| 642 | const textColor = createColorRow("Text color", "color", colorsSection); | ||
| 643 | const backgroundColor = createColorRow("Background", "background-color", colorsSection); | ||
| 644 | |||
| 645 | const opacity = document.createElement("input"); | ||
| 646 | opacity.type = "range"; | ||
| 647 | opacity.min = "0"; | ||
| 648 | opacity.max = "1"; | ||
| 649 | opacity.step = "0.05"; | ||
| 650 | opacity.value = "1"; | ||
| 651 | opacity.style.accentColor = PRIMARY; | ||
| 652 | opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value)); | ||
| 653 | colorsSection.appendChild(createField("Opacity", opacity)); | ||
| 654 | |||
| 655 | const radius = createUnitInput("px", "0"); | ||
| 656 | radius.min = "0"; | ||
| 657 | radius.max = "300"; | ||
| 658 | radius.addEventListener("input", () => { | ||
| 659 | if (radius.value) setStyleForSelected("border-radius", `${radius.value}px`); | ||
| 660 | }); | ||
| 661 | bordersSection.appendChild(createField("Radius", radius)); | ||
| 662 | |||
| 663 | const borderColor = createColorRow("Border color", "border-color", bordersSection); | ||
| 664 | |||
| 665 | const borderWidth = createUnitInput("px", "0"); | ||
| 666 | borderWidth.min = "0"; | ||
| 667 | borderWidth.max = "100"; | ||
| 668 | borderWidth.addEventListener("input", () => { | ||
| 669 | if (borderWidth.value) { | ||
| 670 | setStyleForSelected("border-style", "solid"); | ||
| 671 | setStyleForSelected("border-width", `${borderWidth.value}px`); | ||
| 672 | } | ||
| 673 | }); | ||
| 674 | bordersSection.appendChild(createField("Border width", borderWidth)); | ||
| 675 | |||
| 676 | const dimensions = document.createElement("div"); | ||
| 677 | dimensions.style.cssText = | ||
| 678 | "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center"; | ||
| 679 | const dimensionLabel = document.createElement("div"); | ||
| 680 | dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground"; | ||
| 681 | dimensionLabel.innerHTML = "<span>Width</span><span>Height</span>"; | ||
| 682 | const dimensionControls = document.createElement("div"); | ||
| 683 | dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px"; | ||
| 684 | const widthInput = createUnitInput("px", "auto"); | ||
| 685 | const heightInput = createUnitInput("px", "auto"); | ||
| 686 | styleControl(widthInput); | ||
| 687 | styleControl(heightInput); | ||
| 688 | const aspectLock = createButton("", "Lock aspect ratio"); | ||
| 689 | aspectLock.setAttribute("aria-pressed", "true"); | ||
| 690 | aspectLock.style.cssText += | ||
| 691 | ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0"; | ||
| 692 | aspectLock.className += " bg-primary/10 text-primary"; | ||
| 693 | dimensionControls.append( | ||
| 694 | createUnitControl(widthInput), | ||
| 695 | createUnitControl(heightInput), | ||
| 696 | aspectLock, | ||
| 697 | ); | ||
| 698 | dimensions.append(dimensionLabel, dimensionControls); | ||
| 699 | sizingSection.appendChild(dimensions); | ||
| 700 | |||
| 701 | let aspectLocked = true; | ||
| 702 | let aspectRatio = 1; | ||
| 703 | const refreshAspectButton = (): void => { | ||
| 704 | aspectLock.innerHTML = aspectLocked | ||
| 705 | ? '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="M8 6.5 9.5 5A3.5 3.5 0 0 1 14.5 10l-1.5 1.5M12 13.5 10.5 15A3.5 3.5 0 0 1 5.5 10L7 8.5M7.5 12.5l5-5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>' | ||
| 706 | : '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m6 6 8 8M8 6.5 9.5 5A3.5 3.5 0 0 1 14 9M12 13.5 10.5 15A3.5 3.5 0 0 1 6 11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>'; | ||
| 707 | aspectLock.setAttribute("aria-pressed", String(aspectLocked)); | ||
| 708 | aspectLock.classList.toggle("bg-primary/10", aspectLocked); | ||
| 709 | aspectLock.classList.toggle("text-primary", aspectLocked); | ||
| 710 | aspectLock.classList.toggle("bg-muted", !aspectLocked); | ||
| 711 | aspectLock.classList.toggle("text-muted-foreground", !aspectLocked); | ||
| 712 | }; | ||
| 713 | aspectLock.addEventListener("click", () => { | ||
| 714 | aspectLocked = !aspectLocked; | ||
| 715 | refreshAspectButton(); | ||
| 716 | }); | ||
| 717 | widthInput.addEventListener("input", () => { | ||
| 718 | const width = Number(widthInput.value); | ||
| 719 | if (!Number.isFinite(width) || width <= 0) return; | ||
| 720 | setStyleForSelected("width", `${width}px`); | ||
| 721 | if (aspectLocked && aspectRatio > 0) { | ||
| 722 | const height = Math.max(1, Math.round(width / aspectRatio)); | ||
| 723 | heightInput.value = String(height); | ||
| 724 | setStyleForSelected("height", `${height}px`); | ||
| 725 | } | ||
| 726 | }); | ||
| 727 | heightInput.addEventListener("input", () => { | ||
| 728 | const height = Number(heightInput.value); | ||
| 729 | if (!Number.isFinite(height) || height <= 0) return; | ||
| 730 | setStyleForSelected("height", `${height}px`); | ||
| 731 | if (aspectLocked && aspectRatio > 0) { | ||
| 732 | const width = Math.max(1, Math.round(height * aspectRatio)); | ||
| 733 | widthInput.value = String(width); | ||
| 734 | setStyleForSelected("width", `${width}px`); | ||
| 735 | } | ||
| 736 | }); | ||
| 737 | refreshAspectButton(); | ||
| 738 | |||
| 739 | const addSpacingField = ( | ||
| 740 | label: string, | ||
| 741 | property: string, | ||
| 742 | placeholder: string, | ||
| 743 | ): HTMLInputElement => { | ||
| 744 | const input = document.createElement("input"); | ||
| 745 | input.type = "text"; | ||
| 746 | input.placeholder = placeholder; | ||
| 747 | input.addEventListener("change", () => { | ||
| 748 | if (input.value.trim()) setStyleForSelected(property, input.value.trim()); | ||
| 749 | }); | ||
| 750 | sizingSection.appendChild(createField(label, input)); | ||
| 751 | return input; | ||
| 752 | }; | ||
| 753 | const padding = addSpacingField("Padding", "padding", "0 0 0 0"); | ||
| 754 | const margin = addSpacingField("Margin", "margin", "0 0 0 0"); | ||
| 755 | const gap = addSpacingField("Gap", "gap", "0px"); | ||
| 756 | |||
| 757 | const syncStyleControls = (): void => { | ||
| 758 | const first = selected.values().next().value as SelectedElement | undefined; | ||
| 759 | if (!first) return; | ||
| 760 | const computed = getComputedStyle(first.element); | ||
| 761 | const rect = first.element.getBoundingClientRect(); | ||
| 762 | aspectRatio = rect.height > 0 ? rect.width / rect.height : 1; | ||
| 763 | widthInput.value = String(Math.round(rect.width)); | ||
| 764 | heightInput.value = String(Math.round(rect.height)); | ||
| 765 | fontSize.value = String(Math.round(Number.parseFloat(computed.fontSize) || 16)); | ||
| 766 | fontWeight.value = computed.fontWeight.match(/^[0-9]+$/) ? computed.fontWeight : "400"; | ||
| 767 | lineHeight.value = computed.lineHeight; | ||
| 768 | fontFamily.value = Array.from(fontFamily.options).some( | ||
| 769 | (option) => option.value === computed.fontFamily, | ||
| 770 | ) | ||
| 771 | ? computed.fontFamily | ||
| 772 | : "inherit"; | ||
| 773 | textColor.text.value = computed.color; | ||
| 774 | backgroundColor.text.value = computed.backgroundColor; | ||
| 775 | borderColor.text.value = computed.borderColor; | ||
| 776 | opacity.value = computed.opacity; | ||
| 777 | radius.value = String(Math.round(Number.parseFloat(computed.borderRadius) || 0)); | ||
| 778 | borderWidth.value = String(Math.round(Number.parseFloat(computed.borderWidth) || 0)); | ||
| 779 | padding.value = computed.padding; | ||
| 780 | margin.value = computed.margin; | ||
| 781 | gap.value = computed.gap === "normal" ? "0px" : computed.gap; | ||
| 782 | }; | ||
| 783 | |||
| 784 | const tools: ReadonlyArray<[AnnotationTool, string, string]> = [ | ||
| 785 | ["select", "Select", "Select elements (V)"], | ||
| 786 | ["marquee", "Region", "Draw a region or marquee-select elements (R)"], | ||
| 787 | ["draw", "Draw", "Draw freehand (D)"], | ||
| 788 | ["erase", "Erase", "Remove an annotation target (E)"], | ||
| 789 | ]; | ||
| 790 | for (const [candidate, label, title] of tools) { | ||
| 791 | const button = createButton(label, title); | ||
| 792 | button.className += " h-8 px-2.5 text-sm"; | ||
| 793 | button.addEventListener("click", () => { | ||
| 794 | tool = candidate; | ||
| 795 | refreshToolButtons(); | ||
| 796 | }); | ||
| 797 | toolButtons.set(candidate, button); | ||
| 798 | toolbar.appendChild(button); | ||
| 799 | } | ||
| 800 | |||
| 801 | const clampEditorPosition = (left: number, top: number): { left: number; top: number } => { | ||
| 802 | const margin = 8; | ||
| 803 | const rect = editor.getBoundingClientRect(); | ||
| 804 | return { | ||
| 805 | left: Math.min( | ||
| 806 | Math.max(margin, left), | ||
| 807 | Math.max(margin, window.innerWidth - rect.width - margin), | ||
| 808 | ), | ||
| 809 | top: Math.min( | ||
| 810 | Math.max(margin, top), | ||
| 811 | Math.max(margin, window.innerHeight - rect.height - margin), | ||
| 812 | ), | ||
| 813 | }; | ||
| 814 | }; | ||
| 815 | |||
| 816 | const applyEditorPosition = (position: { left: number; top: number }): void => { | ||
| 817 | const clamped = clampEditorPosition(position.left, position.top); | ||
| 818 | editor.style.left = `${clamped.left}px`; | ||
| 819 | editor.style.top = `${clamped.top}px`; | ||
| 820 | editor.style.right = "auto"; | ||
| 821 | editor.style.bottom = "auto"; | ||
| 822 | if (editorExpanded) editorPosition = clamped; | ||
| 823 | }; | ||
| 824 | |||
| 825 | const getAnnotationBounds = (): PreviewAnnotationRect | null => | ||
| 826 | unionRects( | ||
| 827 | [ | ||
| 828 | ...Array.from(selected.values(), (target) => | ||
| 829 | rectFromDomRect(target.element.getBoundingClientRect()), | ||
| 830 | ), | ||
| 831 | ...regions.map((region) => region.rect), | ||
| 832 | ...strokes.map((stroke) => stroke.bounds), | ||
| 833 | ], | ||
| 834 | 0, | ||
| 835 | ); | ||
| 836 | |||
| 837 | const positionCompactEditor = (): void => { | ||
| 838 | const bounds = getAnnotationBounds(); | ||
| 839 | if (!bounds) return; | ||
| 840 | const editorRect = editor.getBoundingClientRect(); | ||
| 841 | const gap = 8; | ||
| 842 | const candidates = [ | ||
| 843 | { left: bounds.x + bounds.width + gap, top: bounds.y }, | ||
| 844 | { left: bounds.x - editorRect.width - gap, top: bounds.y }, | ||
| 845 | { | ||
| 846 | left: bounds.x + bounds.width - editorRect.width, | ||
| 847 | top: bounds.y + bounds.height + gap, | ||
| 848 | }, | ||
| 849 | { | ||
| 850 | left: bounds.x + bounds.width - editorRect.width, | ||
| 851 | top: bounds.y - editorRect.height - gap, | ||
| 852 | }, | ||
| 853 | ]; | ||
| 854 | const overflow = (position: { left: number; top: number }): number => | ||
| 855 | Math.max(0, -position.left) + | ||
| 856 | Math.max(0, -position.top) + | ||
| 857 | Math.max(0, position.left + editorRect.width - window.innerWidth) + | ||
| 858 | Math.max(0, position.top + editorRect.height - window.innerHeight); | ||
| 859 | const best = candidates.reduce((current, candidate) => | ||
| 860 | overflow(candidate) < overflow(current) ? candidate : current, | ||
| 861 | ); | ||
| 862 | applyEditorPosition(best); | ||
| 863 | }; | ||
| 864 | |||
| 865 | function queueEditorLayout(): void { | ||
| 866 | if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); | ||
| 867 | editorLayoutFrame = window.requestAnimationFrame(() => { | ||
| 868 | editorLayoutFrame = null; | ||
| 869 | if (editor.style.display === "none") return; | ||
| 870 | if (editorExpanded && editorPosition) applyEditorPosition(editorPosition); | ||
| 871 | else positionCompactEditor(); | ||
| 872 | }); | ||
| 873 | } | ||
| 874 | |||
| 875 | adjust.addEventListener("click", () => { | ||
| 876 | if (selected.size === 0) return; | ||
| 877 | if (!editorExpanded) { | ||
| 878 | const rect = editor.getBoundingClientRect(); | ||
| 879 | editorExpanded = true; | ||
| 880 | editorPosition = { left: rect.left, top: rect.top }; | ||
| 881 | stylePanel.style.display = selected.size > 0 ? "grid" : "none"; | ||
| 882 | dragHandle.style.display = "block"; | ||
| 883 | adjust.setAttribute("aria-expanded", "true"); | ||
| 884 | adjust.title = "Collapse annotation editor"; | ||
| 885 | adjust.setAttribute("aria-label", "Collapse annotation editor"); | ||
| 886 | if (selected.size > 0) syncStyleControls(); | ||
| 887 | } else { | ||
| 888 | editorExpanded = false; | ||
| 889 | editorPosition = null; | ||
| 890 | stylePanel.style.display = "none"; | ||
| 891 | dragHandle.style.display = "none"; | ||
| 892 | adjust.setAttribute("aria-expanded", "false"); | ||
| 893 | adjust.title = "Expand annotation editor"; | ||
| 894 | adjust.setAttribute("aria-label", "Expand annotation editor"); | ||
| 895 | } | ||
| 896 | queueEditorLayout(); | ||
| 897 | }); | ||
| 898 | |||
| 899 | const onEditorPointerDown = (event: PointerEvent): void => { | ||
| 900 | if (event.button !== 0 || !editorExpanded) return; | ||
| 901 | const rect = editor.getBoundingClientRect(); | ||
| 902 | editorDrag = { | ||
| 903 | pointerId: event.pointerId, | ||
| 904 | offsetX: event.clientX - rect.left, | ||
| 905 | offsetY: event.clientY - rect.top, | ||
| 906 | }; | ||
| 907 | dragHandle.setPointerCapture(event.pointerId); | ||
| 908 | dragHandle.style.cursor = "grabbing"; | ||
| 909 | event.preventDefault(); | ||
| 910 | event.stopPropagation(); | ||
| 911 | }; | ||
| 912 | |||
| 913 | const onEditorPointerMove = (event: PointerEvent): void => { | ||
| 914 | if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; | ||
| 915 | applyEditorPosition({ | ||
| 916 | left: event.clientX - editorDrag.offsetX, | ||
| 917 | top: event.clientY - editorDrag.offsetY, | ||
| 918 | }); | ||
| 919 | event.preventDefault(); | ||
| 920 | event.stopPropagation(); | ||
| 921 | }; | ||
| 922 | |||
| 923 | const onEditorPointerUp = (event: PointerEvent): void => { | ||
| 924 | if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; | ||
| 925 | editorDrag = null; | ||
| 926 | dragHandle.style.cursor = "grab"; | ||
| 927 | if (dragHandle.hasPointerCapture(event.pointerId)) | ||
| 928 | dragHandle.releasePointerCapture(event.pointerId); | ||
| 929 | event.preventDefault(); | ||
| 930 | event.stopPropagation(); | ||
| 931 | }; | ||
| 932 | dragHandle.addEventListener("pointerdown", onEditorPointerDown); | ||
| 933 | dragHandle.addEventListener("pointermove", onEditorPointerMove); | ||
| 934 | dragHandle.addEventListener("pointerup", onEditorPointerUp); | ||
| 935 | dragHandle.addEventListener("pointercancel", onEditorPointerUp); | ||
| 936 | |||
| 937 | const repaint = (): void => { | ||
| 938 | for (const target of selected.values()) updateSelectedVisual(target); | ||
| 939 | queueEditorLayout(); | ||
| 940 | }; | ||
| 941 | |||
| 942 | const removeTargetAtPoint = (x: number, y: number): boolean => { | ||
| 943 | for (const target of Array.from(selected.values()).toReversed()) { | ||
| 944 | const rect = target.element.getBoundingClientRect(); | ||
| 945 | if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { | ||
| 946 | removeSelected(target); | ||
| 947 | return true; | ||
| 948 | } | ||
| 949 | } | ||
| 950 | const regionIndex = regions.findIndex( | ||
| 951 | (region) => | ||
| 952 | x >= region.rect.x && | ||
| 953 | x <= region.rect.x + region.rect.width && | ||
| 954 | y >= region.rect.y && | ||
| 955 | y <= region.rect.y + region.rect.height, | ||
| 956 | ); | ||
| 957 | if (regionIndex >= 0) { | ||
| 958 | const [removed] = regions.splice(regionIndex, 1); | ||
| 959 | root.querySelector(`[data-region-id="${removed?.id}"]`)?.remove(); | ||
| 960 | updateStatus(); | ||
| 961 | return true; | ||
| 962 | } | ||
| 963 | const strokeIndex = strokes.findIndex( | ||
| 964 | (stroke) => | ||
| 965 | x >= stroke.bounds.x && | ||
| 966 | x <= stroke.bounds.x + stroke.bounds.width && | ||
| 967 | y >= stroke.bounds.y && | ||
| 968 | y <= stroke.bounds.y + stroke.bounds.height, | ||
| 969 | ); | ||
| 970 | if (strokeIndex >= 0) { | ||
| 971 | const [removed] = strokes.splice(strokeIndex, 1); | ||
| 972 | svg.querySelector(`[data-stroke-id="${removed?.id}"]`)?.remove(); | ||
| 973 | updateStatus(); | ||
| 974 | return true; | ||
| 975 | } | ||
| 976 | return false; | ||
| 977 | }; | ||
| 978 | |||
| 979 | const selectElementsInRect = (rect: PreviewAnnotationRect): number => { | ||
| 980 | const candidates = Array.from(document.querySelectorAll("body *")) | ||
| 981 | .filter((element) => !isAnnotationNode(element)) | ||
| 982 | .map((element) => ({ element, rect: element.getBoundingClientRect() })) | ||
| 983 | .filter(({ rect: candidate }) => { | ||
| 984 | if (candidate.width < 2 || candidate.height < 2) return false; | ||
| 985 | return !( | ||
| 986 | candidate.right < rect.x || | ||
| 987 | candidate.left > rect.x + rect.width || | ||
| 988 | candidate.bottom < rect.y || | ||
| 989 | candidate.top > rect.y + rect.height | ||
| 990 | ); | ||
| 991 | }) | ||
| 992 | .filter(({ element, rect: candidate }) => { | ||
| 993 | const centerX = candidate.left + candidate.width / 2; | ||
| 994 | const centerY = candidate.top + candidate.height / 2; | ||
| 995 | return ( | ||
| 996 | centerX >= rect.x && | ||
| 997 | centerX <= rect.x + rect.width && | ||
| 998 | centerY >= rect.y && | ||
| 999 | centerY <= rect.y + rect.height && | ||
| 1000 | (element.children.length === 0 || | ||
| 1001 | element instanceof HTMLButtonElement || | ||
| 1002 | element instanceof HTMLAnchorElement || | ||
| 1003 | element.getAttribute("role") === "button") | ||
| 1004 | ); | ||
| 1005 | }) | ||
| 1006 | .sort( | ||
| 1007 | (left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height, | ||
| 1008 | ) | ||
| 1009 | .slice(0, MAX_MARQUEE_ELEMENTS); | ||
| 1010 | for (const candidate of candidates) addSelected(candidate.element); | ||
| 1011 | return candidates.length; | ||
| 1012 | }; | ||
| 1013 | |||
| 1014 | const clearHoverOutline = (): void => { | ||
| 1015 | hoverOutline.style.display = "none"; | ||
| 1016 | }; | ||
| 1017 | |||
| 1018 | const onPointerMove = (event: PointerEvent): void => { | ||
| 1019 | if (isAnnotationNode(event.target as Element)) { | ||
| 1020 | clearHoverOutline(); | ||
| 1021 | return; | ||
| 1022 | } | ||
| 1023 | if (tool === "select" && dragStart === null) { | ||
| 1024 | const target = pickFromPoint(event.clientX, event.clientY); | ||
| 1025 | if (target) positionBox(hoverOutline, rectFromDomRect(target.getBoundingClientRect())); | ||
| 1026 | else clearHoverOutline(); | ||
| 1027 | return; | ||
| 1028 | } | ||
| 1029 | clearHoverOutline(); | ||
| 1030 | if (tool === "marquee" && dragStart) { | ||
| 1031 | positionBox( | ||
| 1032 | marqueeBox, | ||
| 1033 | normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY), | ||
| 1034 | ); | ||
| 1035 | return; | ||
| 1036 | } | ||
| 1037 | if (tool === "draw" && activeStroke) { | ||
| 1038 | activeStroke.target.points = [ | ||
| 1039 | ...activeStroke.target.points, | ||
| 1040 | { x: event.clientX, y: event.clientY }, | ||
| 1041 | ]; | ||
| 1042 | activeStroke.target.bounds = strokeBounds( | ||
| 1043 | activeStroke.target.points, | ||
| 1044 | activeStroke.target.width, | ||
| 1045 | ); | ||
| 1046 | activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points)); | ||
| 1047 | } | ||
| 1048 | }; | ||
| 1049 | |||
| 1050 | const onPointerDown = (event: PointerEvent): void => { | ||
| 1051 | if (event.button !== 0 || isAnnotationNode(event.target as Element)) return; | ||
| 1052 | event.preventDefault(); | ||
| 1053 | event.stopPropagation(); | ||
| 1054 | if (tool === "select") { | ||
| 1055 | const target = pickFromPoint(event.clientX, event.clientY); | ||
| 1056 | if (target) toggleSelected(target, event.shiftKey); | ||
| 1057 | return; | ||
| 1058 | } | ||
| 1059 | if (tool === "erase") { | ||
| 1060 | removeTargetAtPoint(event.clientX, event.clientY); | ||
| 1061 | return; | ||
| 1062 | } | ||
| 1063 | dragStart = { x: event.clientX, y: event.clientY }; | ||
| 1064 | if (tool === "draw") { | ||
| 1065 | const stroke: PreviewAnnotationStrokeTarget = { | ||
| 1066 | id: nextId("stroke"), | ||
| 1067 | color: annotationTheme?.primary ?? "#2563eb", | ||
| 1068 | width: 4, | ||
| 1069 | points: [dragStart], | ||
| 1070 | bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, | ||
| 1071 | }; | ||
| 1072 | const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); | ||
| 1073 | path.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 1074 | path.setAttribute("data-stroke-id", stroke.id); | ||
| 1075 | path.setAttribute("fill", "none"); | ||
| 1076 | path.setAttribute("stroke", stroke.color); | ||
| 1077 | path.setAttribute("stroke-width", String(stroke.width)); | ||
| 1078 | path.setAttribute("stroke-linecap", "round"); | ||
| 1079 | path.setAttribute("stroke-linejoin", "round"); | ||
| 1080 | svg.appendChild(path); | ||
| 1081 | activeStroke = { target: stroke, path }; | ||
| 1082 | } | ||
| 1083 | }; | ||
| 1084 | |||
| 1085 | const onPointerUp = (event: PointerEvent): void => { | ||
| 1086 | if (!dragStart) return; | ||
| 1087 | event.preventDefault(); | ||
| 1088 | event.stopPropagation(); | ||
| 1089 | if (tool === "marquee") { | ||
| 1090 | const rect = normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY); | ||
| 1091 | marqueeBox.style.display = "none"; | ||
| 1092 | if (isUsableRect(rect)) { | ||
| 1093 | const found = selectElementsInRect(rect); | ||
| 1094 | if (found === 0) { | ||
| 1095 | const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; | ||
| 1096 | regions.push(region); | ||
| 1097 | const regionBox = createBox( | ||
| 1098 | PRIMARY, | ||
| 1099 | "color-mix(in srgb, var(--t3-primary) 6%, transparent)", | ||
| 1100 | ); | ||
| 1101 | regionBox.setAttribute("data-region-id", region.id); | ||
| 1102 | positionBox(regionBox, rect); | ||
| 1103 | root.appendChild(regionBox); | ||
| 1104 | } | ||
| 1105 | } | ||
| 1106 | } else if (tool === "draw" && activeStroke) { | ||
| 1107 | if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target); | ||
| 1108 | else activeStroke.path.remove(); | ||
| 1109 | activeStroke = null; | ||
| 1110 | } | ||
| 1111 | dragStart = null; | ||
| 1112 | updateStatus(); | ||
| 1113 | }; | ||
| 1114 | |||
| 1115 | const onClick = (event: MouseEvent): void => { | ||
| 1116 | if (isAnnotationNode(event.target as Element)) return; | ||
| 1117 | event.preventDefault(); | ||
| 1118 | event.stopPropagation(); | ||
| 1119 | }; | ||
| 1120 | |||
| 1121 | const onPointerOut = (event: PointerEvent): void => { | ||
| 1122 | if (event.relatedTarget === null) clearHoverOutline(); | ||
| 1123 | }; | ||
| 1124 | |||
| 1125 | const onWindowBlur = (): void => { | ||
| 1126 | clearHoverOutline(); | ||
| 1127 | }; | ||
| 1128 | |||
| 1129 | const restoreStyles = (): void => { | ||
| 1130 | for (const target of selected.values()) { | ||
| 1131 | if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) | ||
| 1132 | continue; | ||
| 1133 | for (const [property, baseline] of target.baselineStyles) { | ||
| 1134 | if (baseline) target.element.style.setProperty(property, baseline); | ||
| 1135 | else target.element.style.removeProperty(property); | ||
| 1136 | } | ||
| 1137 | } | ||
| 1138 | }; | ||
| 1139 | |||
| 1140 | const teardown = (notifyMain: boolean): void => { | ||
| 1141 | if (finished) return; | ||
| 1142 | finished = true; | ||
| 1143 | restoreStyles(); | ||
| 1144 | window.removeEventListener("pointermove", onPointerMove, true); | ||
| 1145 | window.removeEventListener("pointerdown", onPointerDown, true); | ||
| 1146 | window.removeEventListener("pointerup", onPointerUp, true); | ||
| 1147 | window.removeEventListener("pointerout", onPointerOut, true); | ||
| 1148 | window.removeEventListener("click", onClick, true); | ||
| 1149 | window.removeEventListener("blur", onWindowBlur); | ||
| 1150 | window.removeEventListener("keydown", onKeyDown, true); | ||
| 1151 | window.removeEventListener("scroll", repaint, true); | ||
| 1152 | window.removeEventListener("resize", repaint); | ||
| 1153 | dragHandle.removeEventListener("pointerdown", onEditorPointerDown); | ||
| 1154 | dragHandle.removeEventListener("pointermove", onEditorPointerMove); | ||
| 1155 | dragHandle.removeEventListener("pointerup", onEditorPointerUp); | ||
| 1156 | dragHandle.removeEventListener("pointercancel", onEditorPointerUp); | ||
| 1157 | if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); | ||
| 1158 | ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); | ||
| 1159 | ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); | ||
| 1160 | document.documentElement.removeAttribute("data-t3code-annotation-tool"); | ||
| 1161 | cursorStyle.remove(); | ||
| 1162 | host.remove(); | ||
| 1163 | activeSession = null; | ||
| 1164 | if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); | ||
| 1165 | }; | ||
| 1166 | |||
| 1167 | const onCancel = (): void => teardown(false); | ||
| 1168 | const onCaptured = (): void => teardown(false); | ||
| 1169 | const onKeyDown = (event: KeyboardEvent): void => { | ||
| 1170 | if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return; | ||
| 1171 | if (event.key === "Escape") { | ||
| 1172 | event.preventDefault(); | ||
| 1173 | event.stopPropagation(); | ||
| 1174 | teardown(true); | ||
| 1175 | return; | ||
| 1176 | } | ||
| 1177 | if (event.key === "v") tool = "select"; | ||
| 1178 | else if (event.key === "r") tool = "marquee"; | ||
| 1179 | else if (event.key === "d") tool = "draw"; | ||
| 1180 | else if (event.key === "e") tool = "erase"; | ||
| 1181 | else return; | ||
| 1182 | refreshToolButtons(); | ||
| 1183 | }; | ||
| 1184 | |||
| 1185 | submit.addEventListener("click", () => { | ||
| 1186 | if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) | ||
| 1187 | return; | ||
| 1188 | pendingCapture = true; | ||
| 1189 | submit.disabled = true; | ||
| 1190 | submit.textContent = "Capturing…"; | ||
| 1191 | void Promise.all( | ||
| 1192 | Array.from(selected.values()).map(async (target) => { | ||
| 1193 | const element = await captureElement(target.element); | ||
| 1194 | if (!element) return null; | ||
| 1195 | for (const change of styleChanges.values()) { | ||
| 1196 | if (change.targetId === target.id) change.selector = element.selector; | ||
| 1197 | } | ||
| 1198 | return { | ||
| 1199 | id: target.id, | ||
| 1200 | element, | ||
| 1201 | rect: rectFromDomRect(target.element.getBoundingClientRect()), | ||
| 1202 | }; | ||
| 1203 | }), | ||
| 1204 | ).then((captured) => { | ||
| 1205 | const elements = captured.filter((target) => target !== null); | ||
| 1206 | const annotation: PreviewAnnotationPayload = { | ||
| 1207 | id: nextId("annotation"), | ||
| 1208 | pageUrl: location.href, | ||
| 1209 | pageTitle: document.title?.trim() || null, | ||
| 1210 | comment: comment.value.trim(), | ||
| 1211 | elements, | ||
| 1212 | regions: [...regions], | ||
| 1213 | strokes: [...strokes], | ||
| 1214 | styleChanges: Array.from(styleChanges.values()), | ||
| 1215 | screenshot: null, | ||
| 1216 | createdAt: new Date().toISOString(), | ||
| 1217 | }; | ||
| 1218 | editor.style.display = "none"; | ||
| 1219 | toolbar.style.display = "none"; | ||
| 1220 | hoverOutline.style.display = "none"; | ||
| 1221 | const screenshotRect = unionRects([ | ||
| 1222 | ...elements.map((target) => target.rect), | ||
| 1223 | ...regions.map((region) => region.rect), | ||
| 1224 | ...strokes.map((stroke) => stroke.bounds), | ||
| 1225 | ]); | ||
| 1226 | ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect); | ||
| 1227 | }); | ||
| 1228 | }); | ||
| 1229 | comment.addEventListener("keydown", (event) => { | ||
| 1230 | if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return; | ||
| 1231 | event.preventDefault(); | ||
| 1232 | submit.click(); | ||
| 1233 | }); | ||
| 1234 | |||
| 1235 | window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); | ||
| 1236 | window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false }); | ||
| 1237 | window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false }); | ||
| 1238 | window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true }); | ||
| 1239 | window.addEventListener("click", onClick, { capture: true, passive: false }); | ||
| 1240 | window.addEventListener("blur", onWindowBlur); | ||
| 1241 | window.addEventListener("keydown", onKeyDown, { capture: true }); | ||
| 1242 | window.addEventListener("scroll", repaint, { capture: true, passive: true }); | ||
| 1243 | window.addEventListener("resize", repaint, { passive: true }); | ||
| 1244 | ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); | ||
| 1245 | ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); | ||
| 1246 | document.documentElement.appendChild(host); | ||
| 1247 | refreshToolButtons(); | ||
| 1248 | updateStatus(); | ||
| 1249 | activeSession = { | ||
| 1250 | teardown, | ||
| 1251 | applyTheme: (theme) => applyAnnotationTheme(host, theme), | ||
| 1252 | }; | ||
| 1253 | } | ||
| 1254 | |||
| 1255 | ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => { | ||
| 1256 | if (theme) annotationTheme = theme; | ||
| 1257 | startAnnotation(); | ||
| 1258 | }); | ||
| 1259 | ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { | ||
| 1260 | annotationTheme = theme; | ||
| 1261 | activeSession?.applyTheme(theme); | ||
| 1262 | }); | ||
| 1263 | ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); | ||
apps/desktop/src/preview/PickedElementPayload.test.ts created+201| ... | @@ -0,0 +1,201 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { isPickedElementPayload, isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; | ||
| 4 | |||
| 5 | function validPayload(overrides?: Record<string, unknown>): Record<string, unknown> { | ||
| 6 | return { | ||
| 7 | pageUrl: "https://example.com/", | ||
| 8 | pageTitle: "Example", | ||
| 9 | tagName: "button", | ||
| 10 | selector: "button.submit", | ||
| 11 | htmlPreview: "<button>Save</button>", | ||
| 12 | componentName: "SubmitButton", | ||
| 13 | source: { | ||
| 14 | functionName: "SubmitButton", | ||
| 15 | fileName: "/repo/src/Button.tsx", | ||
| 16 | lineNumber: 12, | ||
| 17 | columnNumber: 5, | ||
| 18 | }, | ||
| 19 | stack: [ | ||
| 20 | { | ||
| 21 | functionName: "SubmitButton", | ||
| 22 | fileName: "/repo/src/Button.tsx", | ||
| 23 | lineNumber: 12, | ||
| 24 | columnNumber: 5, | ||
| 25 | }, | ||
| 26 | ], | ||
| 27 | styles: ".submit { color: white; }", | ||
| 28 | pickedAt: "2026-05-03T18:00:00.000Z", | ||
| 29 | ...overrides, | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | |||
| 33 | describe("isPickedElementPayload", () => { | ||
| 34 | it("accepts a complete, well-typed payload", () => { | ||
| 35 | expect(isPickedElementPayload(validPayload())).toBe(true); | ||
| 36 | }); | ||
| 37 | |||
| 38 | it("accepts nullable string fields when null", () => { | ||
| 39 | expect( | ||
| 40 | isPickedElementPayload( | ||
| 41 | validPayload({ pageTitle: null, selector: null, componentName: null, source: null }), | ||
| 42 | ), | ||
| 43 | ).toBe(true); | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("accepts an empty stack array", () => { | ||
| 47 | expect(isPickedElementPayload(validPayload({ stack: [] }))).toBe(true); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("accepts stack frames with null fields", () => { | ||
| 51 | expect( | ||
| 52 | isPickedElementPayload( | ||
| 53 | validPayload({ | ||
| 54 | stack: [ | ||
| 55 | { | ||
| 56 | functionName: null, | ||
| 57 | fileName: null, | ||
| 58 | lineNumber: null, | ||
| 59 | columnNumber: null, | ||
| 60 | }, | ||
| 61 | ], | ||
| 62 | }), | ||
| 63 | ), | ||
| 64 | ).toBe(true); | ||
| 65 | }); | ||
| 66 | |||
| 67 | it("rejects null and primitive inputs", () => { | ||
| 68 | expect(isPickedElementPayload(null)).toBe(false); | ||
| 69 | expect(isPickedElementPayload(undefined)).toBe(false); | ||
| 70 | expect(isPickedElementPayload("string")).toBe(false); | ||
| 71 | expect(isPickedElementPayload(42)).toBe(false); | ||
| 72 | expect(isPickedElementPayload([])).toBe(false); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it.each<[string, Record<string, unknown>]>([ | ||
| 76 | ["missing pageUrl", validPayload({ pageUrl: undefined })], | ||
| 77 | ["wrong-type pageUrl", validPayload({ pageUrl: 123 })], | ||
| 78 | ["missing tagName", validPayload({ tagName: undefined })], | ||
| 79 | ["missing htmlPreview", validPayload({ htmlPreview: undefined })], | ||
| 80 | ["missing styles", validPayload({ styles: undefined })], | ||
| 81 | ["missing pickedAt", validPayload({ pickedAt: undefined })], | ||
| 82 | ["wrong-type pageTitle", validPayload({ pageTitle: 99 })], | ||
| 83 | ["wrong-type selector", validPayload({ selector: 99 })], | ||
| 84 | ["wrong-type componentName", validPayload({ componentName: 99 })], | ||
| 85 | ])("rejects payloads with %s", (_label, value) => { | ||
| 86 | expect(isPickedElementPayload(value)).toBe(false); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("rejects malformed source frames", () => { | ||
| 90 | expect( | ||
| 91 | isPickedElementPayload( | ||
| 92 | validPayload({ | ||
| 93 | source: { | ||
| 94 | functionName: 0, | ||
| 95 | fileName: null, | ||
| 96 | lineNumber: null, | ||
| 97 | columnNumber: null, | ||
| 98 | }, | ||
| 99 | }), | ||
| 100 | ), | ||
| 101 | ).toBe(false); | ||
| 102 | }); | ||
| 103 | |||
| 104 | it("rejects non-finite numeric line/column numbers", () => { | ||
| 105 | expect( | ||
| 106 | isPickedElementPayload( | ||
| 107 | validPayload({ | ||
| 108 | source: { | ||
| 109 | functionName: null, | ||
| 110 | fileName: null, | ||
| 111 | lineNumber: Number.POSITIVE_INFINITY, | ||
| 112 | columnNumber: null, | ||
| 113 | }, | ||
| 114 | }), | ||
| 115 | ), | ||
| 116 | ).toBe(false); | ||
| 117 | expect( | ||
| 118 | isPickedElementPayload( | ||
| 119 | validPayload({ | ||
| 120 | source: { | ||
| 121 | functionName: null, | ||
| 122 | fileName: null, | ||
| 123 | lineNumber: Number.NaN, | ||
| 124 | columnNumber: null, | ||
| 125 | }, | ||
| 126 | }), | ||
| 127 | ), | ||
| 128 | ).toBe(false); | ||
| 129 | }); | ||
| 130 | |||
| 131 | it("rejects malformed stack arrays", () => { | ||
| 132 | expect(isPickedElementPayload(validPayload({ stack: "not-an-array" }))).toBe(false); | ||
| 133 | expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false); | ||
| 134 | }); | ||
| 135 | }); | ||
| 136 | |||
| 137 | function validAnnotation(overrides?: Record<string, unknown>): Record<string, unknown> { | ||
| 138 | return { | ||
| 139 | id: "annotation_1", | ||
| 140 | pageUrl: "https://example.com/", | ||
| 141 | pageTitle: "Example", | ||
| 142 | comment: "Make this clearer", | ||
| 143 | elements: [ | ||
| 144 | { | ||
| 145 | id: "element_1", | ||
| 146 | element: validPayload(), | ||
| 147 | rect: { x: 10, y: 20, width: 100, height: 40 }, | ||
| 148 | }, | ||
| 149 | ], | ||
| 150 | regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], | ||
| 151 | strokes: [ | ||
| 152 | { | ||
| 153 | id: "stroke_1", | ||
| 154 | color: "#7c3aed", | ||
| 155 | width: 4, | ||
| 156 | points: [ | ||
| 157 | { x: 10, y: 10 }, | ||
| 158 | { x: 20, y: 20 }, | ||
| 159 | ], | ||
| 160 | bounds: { x: 6, y: 6, width: 18, height: 18 }, | ||
| 161 | }, | ||
| 162 | ], | ||
| 163 | styleChanges: [ | ||
| 164 | { | ||
| 165 | targetId: "element_1", | ||
| 166 | selector: "button.submit", | ||
| 167 | property: "opacity", | ||
| 168 | previousValue: "1", | ||
| 169 | value: "0.5", | ||
| 170 | }, | ||
| 171 | ], | ||
| 172 | screenshot: null, | ||
| 173 | createdAt: "2026-06-11T00:00:00.000Z", | ||
| 174 | ...overrides, | ||
| 175 | }; | ||
| 176 | } | ||
| 177 | |||
| 178 | describe("isPreviewAnnotationPayload", () => { | ||
| 179 | it("accepts a structured annotation draft before screenshot capture", () => { | ||
| 180 | expect(isPreviewAnnotationPayload(validAnnotation())).toBe(true); | ||
| 181 | }); | ||
| 182 | |||
| 183 | it("rejects screenshots supplied by the guest preload", () => { | ||
| 184 | expect(isPreviewAnnotationPayload(validAnnotation({ screenshot: { dataUrl: "bad" } }))).toBe( | ||
| 185 | false, | ||
| 186 | ); | ||
| 187 | }); | ||
| 188 | |||
| 189 | it("rejects malformed geometry and nested element payloads", () => { | ||
| 190 | expect( | ||
| 191 | isPreviewAnnotationPayload( | ||
| 192 | validAnnotation({ regions: [{ id: "region_1", rect: { x: 0, y: 0, width: "wide" } }] }), | ||
| 193 | ), | ||
| 194 | ).toBe(false); | ||
| 195 | expect( | ||
| 196 | isPreviewAnnotationPayload( | ||
| 197 | validAnnotation({ elements: [{ id: "element_1", element: {}, rect: {} }] }), | ||
| 198 | ), | ||
| 199 | ).toBe(false); | ||
| 200 | }); | ||
| 201 | }); | ||
apps/desktop/src/preview/PickedElementPayload.ts created+146| ... | @@ -0,0 +1,146 @@ | ||
| 1 | /** | ||
| 2 | * Strict structural validator for `PickedElementPayload` messages received | ||
| 3 | * from the in-page picker preload (`apps/desktop/src/preview/PickPreload.ts`) | ||
| 4 | * via `wc.ipc`. Lives in its own electron-free module so the validator is | ||
| 5 | * trivially unit-testable. | ||
| 6 | * | ||
| 7 | * Validation must be tight: downstream `normalizeElementContextSelection` | ||
| 8 | * calls `.trim()` on incoming strings, so a malformed payload (preload bug, | ||
| 9 | * future schema mismatch, malicious page that intercepts the preload's IPC | ||
| 10 | * channel via prototype pollution) would otherwise throw deep in the | ||
| 11 | * renderer and the chip silently never appears. | ||
| 12 | */ | ||
| 13 | import type { PickedElementPayload, PreviewAnnotationPayload } from "@t3tools/contracts"; | ||
| 14 | |||
| 15 | function isStringOrNull(value: unknown): value is string | null { | ||
| 16 | return value === null || typeof value === "string"; | ||
| 17 | } | ||
| 18 | |||
| 19 | function isFiniteNumberOrNull(value: unknown): value is number | null { | ||
| 20 | return value === null || (typeof value === "number" && Number.isFinite(value)); | ||
| 21 | } | ||
| 22 | |||
| 23 | function isPickedStackFrame(value: unknown): boolean { | ||
| 24 | if (typeof value !== "object" || value === null) return false; | ||
| 25 | const frame = value as Record<string, unknown>; | ||
| 26 | return ( | ||
| 27 | isStringOrNull(frame["functionName"]) && | ||
| 28 | isStringOrNull(frame["fileName"]) && | ||
| 29 | isFiniteNumberOrNull(frame["lineNumber"]) && | ||
| 30 | isFiniteNumberOrNull(frame["columnNumber"]) | ||
| 31 | ); | ||
| 32 | } | ||
| 33 | |||
| 34 | export function isPickedElementPayload(value: unknown): value is PickedElementPayload { | ||
| 35 | if (typeof value !== "object" || value === null) return false; | ||
| 36 | const c = value as Record<string, unknown>; | ||
| 37 | if (typeof c["pageUrl"] !== "string") return false; | ||
| 38 | if (typeof c["tagName"] !== "string") return false; | ||
| 39 | if (typeof c["htmlPreview"] !== "string") return false; | ||
| 40 | if (typeof c["styles"] !== "string") return false; | ||
| 41 | if (typeof c["pickedAt"] !== "string") return false; | ||
| 42 | if (!isStringOrNull(c["pageTitle"])) return false; | ||
| 43 | if (!isStringOrNull(c["selector"])) return false; | ||
| 44 | if (!isStringOrNull(c["componentName"])) return false; | ||
| 45 | if (c["source"] !== null && !isPickedStackFrame(c["source"])) return false; | ||
| 46 | if (!Array.isArray(c["stack"])) return false; | ||
| 47 | if (!c["stack"].every(isPickedStackFrame)) return false; | ||
| 48 | return true; | ||
| 49 | } | ||
| 50 | |||
| 51 | function isRect(value: unknown): boolean { | ||
| 52 | if (typeof value !== "object" || value === null) return false; | ||
| 53 | const rect = value as Record<string, unknown>; | ||
| 54 | return ["x", "y", "width", "height"].every( | ||
| 55 | (key) => typeof rect[key] === "number" && Number.isFinite(rect[key]), | ||
| 56 | ); | ||
| 57 | } | ||
| 58 | |||
| 59 | function isPoint(value: unknown): boolean { | ||
| 60 | if (typeof value !== "object" || value === null) return false; | ||
| 61 | const point = value as Record<string, unknown>; | ||
| 62 | return ( | ||
| 63 | typeof point["x"] === "number" && | ||
| 64 | Number.isFinite(point["x"]) && | ||
| 65 | typeof point["y"] === "number" && | ||
| 66 | Number.isFinite(point["y"]) | ||
| 67 | ); | ||
| 68 | } | ||
| 69 | |||
| 70 | export function isPreviewAnnotationPayload(value: unknown): value is PreviewAnnotationPayload { | ||
| 71 | if (typeof value !== "object" || value === null) return false; | ||
| 72 | const annotation = value as Record<string, unknown>; | ||
| 73 | if (typeof annotation["id"] !== "string") return false; | ||
| 74 | if (typeof annotation["pageUrl"] !== "string") return false; | ||
| 75 | if (!isStringOrNull(annotation["pageTitle"])) return false; | ||
| 76 | if (typeof annotation["comment"] !== "string") return false; | ||
| 77 | if (typeof annotation["createdAt"] !== "string") return false; | ||
| 78 | if (annotation["screenshot"] !== null) return false; | ||
| 79 | |||
| 80 | const elements = annotation["elements"]; | ||
| 81 | if (!Array.isArray(elements)) return false; | ||
| 82 | if ( | ||
| 83 | !elements.every((entry) => { | ||
| 84 | if (typeof entry !== "object" || entry === null) return false; | ||
| 85 | const target = entry as Record<string, unknown>; | ||
| 86 | return ( | ||
| 87 | typeof target["id"] === "string" && | ||
| 88 | isPickedElementPayload(target["element"]) && | ||
| 89 | isRect(target["rect"]) | ||
| 90 | ); | ||
| 91 | }) | ||
| 92 | ) { | ||
| 93 | return false; | ||
| 94 | } | ||
| 95 | |||
| 96 | const regions = annotation["regions"]; | ||
| 97 | if (!Array.isArray(regions)) return false; | ||
| 98 | if ( | ||
| 99 | !regions.every((entry) => { | ||
| 100 | if (typeof entry !== "object" || entry === null) return false; | ||
| 101 | const target = entry as Record<string, unknown>; | ||
| 102 | return typeof target["id"] === "string" && isRect(target["rect"]); | ||
| 103 | }) | ||
| 104 | ) { | ||
| 105 | return false; | ||
| 106 | } | ||
| 107 | |||
| 108 | const strokes = annotation["strokes"]; | ||
| 109 | if (!Array.isArray(strokes)) return false; | ||
| 110 | if ( | ||
| 111 | !strokes.every((entry) => { | ||
| 112 | if (typeof entry !== "object" || entry === null) return false; | ||
| 113 | const target = entry as Record<string, unknown>; | ||
| 114 | return ( | ||
| 115 | typeof target["id"] === "string" && | ||
| 116 | typeof target["color"] === "string" && | ||
| 117 | typeof target["width"] === "number" && | ||
| 118 | Number.isFinite(target["width"]) && | ||
| 119 | Array.isArray(target["points"]) && | ||
| 120 | target["points"].every(isPoint) && | ||
| 121 | isRect(target["bounds"]) | ||
| 122 | ); | ||
| 123 | }) | ||
| 124 | ) { | ||
| 125 | return false; | ||
| 126 | } | ||
| 127 | |||
| 128 | const styleChanges = annotation["styleChanges"]; | ||
| 129 | if (!Array.isArray(styleChanges)) return false; | ||
| 130 | if ( | ||
| 131 | !styleChanges.every((entry) => { | ||
| 132 | if (typeof entry !== "object" || entry === null) return false; | ||
| 133 | const change = entry as Record<string, unknown>; | ||
| 134 | return ( | ||
| 135 | typeof change["targetId"] === "string" && | ||
| 136 | isStringOrNull(change["selector"]) && | ||
| 137 | typeof change["property"] === "string" && | ||
| 138 | typeof change["previousValue"] === "string" && | ||
| 139 | typeof change["value"] === "string" | ||
| 140 | ); | ||
| 141 | }) | ||
| 142 | ) { | ||
| 143 | return false; | ||
| 144 | } | ||
| 145 | return true; | ||
| 146 | } | ||
apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | playwrightInjectedRuntimeInstallExpression, | ||
| 5 | playwrightInjectedRuntimeSource, | ||
| 6 | } from "./PlaywrightInjectedRuntime.ts"; | ||
| 7 | |||
| 8 | describe("playwright injected runtime", () => { | ||
| 9 | it("extracts the pinned runtime from playwright-core", async () => { | ||
| 10 | const source = await playwrightInjectedRuntimeSource(); | ||
| 11 | expect(source.length).toBeGreaterThan(100_000); | ||
| 12 | expect(source).toContain("InjectedScript"); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("builds an idempotent install expression", async () => { | ||
| 16 | const expression = await playwrightInjectedRuntimeInstallExpression(); | ||
| 17 | expect(expression).toContain("__t3PlaywrightInjected"); | ||
| 18 | expect(expression).toContain('testIdAttributeName":"data-testid'); | ||
| 19 | }); | ||
| 20 | }); | ||
apps/desktop/src/preview/PlaywrightInjectedRuntime.ts created+49| ... | @@ -0,0 +1,49 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import { createRequire } from "node:module"; | ||
| 3 | import { dirname, join } from "node:path"; | ||
| 4 | import { readFile } from "node:fs/promises"; | ||
| 5 | import { runInNewContext } from "node:vm"; | ||
| 6 | |||
| 7 | const require = createRequire(import.meta.url); | ||
| 8 | let sourcePromise: Promise<string> | null = null; | ||
| 9 | |||
| 10 | export const playwrightInjectedRuntimeSource = (): Promise<string> => { | ||
| 11 | sourcePromise ??= (async () => { | ||
| 12 | const packageJsonPath = require.resolve("playwright-core/package.json"); | ||
| 13 | const coreBundle = await readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8"); | ||
| 14 | const marker = "source3 = "; | ||
| 15 | const start = coreBundle.indexOf(marker); | ||
| 16 | if (start < 0) throw new Error("Playwright injected runtime marker was not found."); | ||
| 17 | const literalStart = start + marker.length; | ||
| 18 | const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart); | ||
| 19 | if (literalEnd < 0) throw new Error("Playwright injected runtime terminator was not found."); | ||
| 20 | const literal = coreBundle.slice(literalStart, literalEnd); | ||
| 21 | const source = runInNewContext(literal, Object.create(null), { timeout: 1_000 }); | ||
| 22 | if (typeof source !== "string" || source.length < 100_000) { | ||
| 23 | throw new Error("Playwright injected runtime extraction returned invalid source."); | ||
| 24 | } | ||
| 25 | return source; | ||
| 26 | })(); | ||
| 27 | return sourcePromise; | ||
| 28 | }; | ||
| 29 | |||
| 30 | export const playwrightInjectedRuntimeInstallExpression = async (): Promise<string> => { | ||
| 31 | const source = await playwrightInjectedRuntimeSource(); | ||
| 32 | const options = { | ||
| 33 | isUnderTest: false, | ||
| 34 | sdkLanguage: "javascript", | ||
| 35 | testIdAttributeName: "data-testid", | ||
| 36 | stableRafCount: 1, | ||
| 37 | browserName: "chromium", | ||
| 38 | shouldPrependErrorPrefix: false, | ||
| 39 | isUtilityWorld: false, | ||
| 40 | customEngines: [], | ||
| 41 | }; | ||
| 42 | return `(() => { | ||
| 43 | if (globalThis.__t3PlaywrightInjected) return true; | ||
| 44 | const module = { exports: {} }; | ||
| 45 | ${source} | ||
| 46 | globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${JSON.stringify(options)}); | ||
| 47 | return true; | ||
| 48 | })()`; | ||
| 49 | }; | ||
apps/desktop/src/preview/WebviewPreferences.test.ts created+78| ... | @@ -0,0 +1,78 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { PREVIEW_WEBVIEW_PREFERENCES } from "./WebviewPreferences.ts"; | ||
| 4 | |||
| 5 | /** | ||
| 6 | * Mirrors Electron's webview attribute parser closely enough to catch the | ||
| 7 | * regressions we've already hit: | ||
| 8 | * | ||
| 9 | * - whitespace inside the comma-separated list silently drops keys (so | ||
| 10 | * `" sandbox=true"` becomes an unknown key and Electron falls back to | ||
| 11 | * defaults — re-opening the Node-leak window we closed), | ||
| 12 | * - non-`true`/`false` values (`"yes"`, `"no"`, etc.) are kept as truthy | ||
| 13 | * strings and assigned to a boolean preference, which silently flips | ||
| 14 | * `contextIsolation=no` to ENABLED (then react-grab can't see the React | ||
| 15 | * DevTools hook and componentName resolution always returns null). | ||
| 16 | * | ||
| 17 | * The actual Electron parser does roughly: | ||
| 18 | * | ||
| 19 | * for (const pair of webpreferences.split(',')) { | ||
| 20 | * const [key, value] = pair.split('='); | ||
| 21 | * prefs[key] = value; // value left as a string | ||
| 22 | * } | ||
| 23 | * | ||
| 24 | * then later coerces booleans via `Boolean(value)`. Replicating that here | ||
| 25 | * keeps the test independent of Electron internals while still failing if | ||
| 26 | * we accidentally ship `"contextIsolation=no"` again. | ||
| 27 | */ | ||
| 28 | function parseWebPreferences(input: string): Record<string, unknown> { | ||
| 29 | const out: Record<string, unknown> = {}; | ||
| 30 | for (const pair of input.split(",")) { | ||
| 31 | if (pair !== pair.trim()) { | ||
| 32 | // Electron's parser doesn't trim; surface the bug as undefined-key. | ||
| 33 | out[pair] = pair.split("=")[1]; | ||
| 34 | continue; | ||
| 35 | } | ||
| 36 | const [key, value] = pair.split("="); | ||
| 37 | if (!key) continue; | ||
| 38 | out[key] = value; | ||
| 39 | } | ||
| 40 | return out; | ||
| 41 | } | ||
| 42 | |||
| 43 | describe("PREVIEW_WEBVIEW_PREFERENCES", () => { | ||
| 44 | const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES); | ||
| 45 | |||
| 46 | it("contains exactly the three security-critical keys", () => { | ||
| 47 | expect(Object.keys(parsed).toSorted()).toEqual( | ||
| 48 | ["contextIsolation", "nodeIntegration", "sandbox"].toSorted(), | ||
| 49 | ); | ||
| 50 | }); | ||
| 51 | |||
| 52 | it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => { | ||
| 53 | // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so | ||
| 54 | // `contextIsolation="no"` would silently leave isolation ENABLED. Lock | ||
| 55 | // the values to `"true"` / `"false"` so the parser does the right thing. | ||
| 56 | for (const value of Object.values(parsed)) { | ||
| 57 | expect(value).toMatch(/^(true|false)$/); | ||
| 58 | } | ||
| 59 | }); | ||
| 60 | |||
| 61 | it("disables context isolation (so react-grab can see the page's React DevTools hook)", () => { | ||
| 62 | expect(parsed["contextIsolation"]).toBe("false"); | ||
| 63 | }); | ||
| 64 | |||
| 65 | it("keeps the renderer sandbox enabled (so the page cannot reach Node APIs)", () => { | ||
| 66 | expect(parsed["sandbox"]).toBe("true"); | ||
| 67 | }); | ||
| 68 | |||
| 69 | it("disables nodeIntegration (defense in depth — page never gets Node)", () => { | ||
| 70 | expect(parsed["nodeIntegration"]).toBe("false"); | ||
| 71 | }); | ||
| 72 | |||
| 73 | it("contains no whitespace (Electron's parser does not trim)", () => { | ||
| 74 | // Electron splits on `,` without trimming, so any whitespace would turn | ||
| 75 | // a key into an unknown one and silently drop the security flag. | ||
| 76 | expect(PREVIEW_WEBVIEW_PREFERENCES).not.toMatch(/\s/); | ||
| 77 | }); | ||
| 78 | }); | ||
apps/desktop/src/preview/WebviewPreferences.ts created+42| ... | @@ -0,0 +1,42 @@ | ||
| 1 | /** | ||
| 2 | * webPreferences override applied to every preview `<webview>` element via | ||
| 3 | * its `webpreferences="..."` attribute. Single source of truth so all guest | ||
| 4 | * surfaces inherit the same security posture. | ||
| 5 | * | ||
| 6 | * Lives in its own electron-free module so the value is unit-testable | ||
| 7 | * without importing `Manager.ts` (which transitively imports | ||
| 8 | * `electron` and blows up under vitest). | ||
| 9 | * | ||
| 10 | * - `contextIsolation=false`: the picker preload needs to share `globalThis` | ||
| 11 | * with the page so react-grab/bippy can read the React DevTools hook | ||
| 12 | * (`__REACT_DEVTOOLS_GLOBAL_HOOK__`) and resolve component names. Without | ||
| 13 | * this every pick comes back with `componentName: null` even on dev React | ||
| 14 | * apps. | ||
| 15 | * - `sandbox=true`: keeps the OS-level renderer sandbox enabled. Critical | ||
| 16 | * when paired with `contextIsolation=false` — without sandbox, the preload | ||
| 17 | * has full Node access (`require`, `fs`, `child_process`, ...) and that | ||
| 18 | * `require` would land on the page's shared `globalThis`, giving any | ||
| 19 | * third-party page in the preview full Node + IPC access to the host. | ||
| 20 | * In sandboxed mode Electron still synthesizes the `electron` module for | ||
| 21 | * the preload's `import { ipcRenderer }` line, but no Node globals leak. | ||
| 22 | * - `nodeIntegration=false`: pinned for clarity (the page itself never gets | ||
| 23 | * Node access). | ||
| 24 | * | ||
| 25 | * Format notes (locked down by `WebviewPreferences.test.ts`): | ||
| 26 | * - Whitespace-free. Electron's webpreferences parser splits on `,` and | ||
| 27 | * does not trim, so a leading space would turn a key into an unknown one | ||
| 28 | * and silently drop it. | ||
| 29 | * - Values are JS-boolean strings (`true`/`false`) — `yes`/`no` are not | ||
| 30 | * special-cased by the parser; `value="no"` becomes the truthy STRING | ||
| 31 | * `"no"` when assigned to a boolean webPreferences key. Most critically, | ||
| 32 | * `contextIsolation="no"` is truthy → contextIsolation stays ENABLED → | ||
| 33 | * react-grab can't see the React DevTools hook. | ||
| 34 | * | ||
| 35 | * Defense in depth: `apps/desktop/src/main.ts` also runs a | ||
| 36 | * `will-attach-webview` handler that force-sets `sandbox: true` and | ||
| 37 | * `nodeIntegration*: false` on the actual webPreferences object, gated on | ||
| 38 | * the preview partition, so even if this string is ever wrong, the | ||
| 39 | * security-critical flags can't regress on preview tabs. | ||
| 40 | */ | ||
| 41 | export const PREVIEW_WEBVIEW_PREFERENCES = | ||
| 42 | "contextIsolation=false,sandbox=true,nodeIntegration=false"; | ||
apps/desktop/src/window/DesktopWindow.test.ts+7| ... | @@ -29,6 +29,7 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts"; | ... | @@ -29,6 +29,7 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| 29 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | 29 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 30 | import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; | 30 | import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; |
| 31 | import * as DesktopWindow from "./DesktopWindow.ts"; | 31 | import * as DesktopWindow from "./DesktopWindow.ts"; |
| 32 | import * as PreviewManager from "../preview/Manager.ts"; | ||
| 32 | 33 | ||
| 33 | const environmentInput = { | 34 | const environmentInput = { |
| 34 | dirname: "/repo/apps/desktop/dist-electron", | 35 | dirname: "/repo/apps/desktop/dist-electron", |
| ... | @@ -166,6 +167,12 @@ function makeTestLayer(input: { | ... | @@ -166,6 +167,12 @@ function makeTestLayer(input: { |
| 166 | } satisfies ElectronShell.ElectronShellShape), | 167 | } satisfies ElectronShell.ElectronShellShape), |
| 167 | electronThemeLayer, | 168 | electronThemeLayer, |
| 168 | electronWindowLayer, | 169 | electronWindowLayer, |
| 170 | Layer.mock(PreviewManager.PreviewManager)({ | ||
| 171 | getBrowserSession: () => Effect.succeed({} as Electron.Session), | ||
| 172 | setMainWindow: () => Effect.void, | ||
| 173 | isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), | ||
| 174 | getBrowserPartition: () => "persist:t3code-preview-test", | ||
| 175 | }), | ||
| 169 | ), | 176 | ), |
| 170 | ), | 177 | ), |
| 171 | ); | 178 | ); |
apps/desktop/src/window/DesktopWindow.ts+9-6| ... | @@ -11,7 +11,7 @@ import * as DesktopAssets from "../app/DesktopAssets.ts"; | ... | @@ -11,7 +11,7 @@ import * as DesktopAssets from "../app/DesktopAssets.ts"; |
| 11 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 11 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 12 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | 12 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 13 | import * as DesktopState from "../app/DesktopState.ts"; | 13 | import * as DesktopState from "../app/DesktopState.ts"; |
| 14 | import { previewViewManager } from "../preview-view-manager.ts"; | 14 | import * as PreviewManager from "../preview/Manager.ts"; |
| 15 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; | 15 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; |
| 16 | import * as ElectronShell from "../electron/ElectronShell.ts"; | 16 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 17 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; | 17 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| ... | @@ -37,7 +37,8 @@ type DesktopWindowRuntimeServices = | ... | @@ -37,7 +37,8 @@ type DesktopWindowRuntimeServices = |
| 37 | | ElectronMenu.ElectronMenu | 37 | | ElectronMenu.ElectronMenu |
| 38 | | ElectronShell.ElectronShell | 38 | | ElectronShell.ElectronShell |
| 39 | | ElectronTheme.ElectronTheme | 39 | | ElectronTheme.ElectronTheme |
| 40 | | ElectronWindow.ElectronWindow; | 40 | | ElectronWindow.ElectronWindow |
| 41 | | PreviewManager.PreviewManager; | ||
| 41 | 42 | ||
| 42 | export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( | 43 | export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( |
| 43 | "DesktopWindowDevServerUrlMissingError", | 44 | "DesktopWindowDevServerUrlMissingError", |
| ... | @@ -49,7 +50,8 @@ export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( | ... | @@ -49,7 +50,8 @@ export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( |
| 49 | 50 | ||
| 50 | export type DesktopWindowError = | 51 | export type DesktopWindowError = |
| 51 | | DesktopWindowDevServerUrlMissingError | 52 | | DesktopWindowDevServerUrlMissingError |
| 52 | | ElectronWindow.ElectronWindowCreateError; | 53 | | ElectronWindow.ElectronWindowCreateError |
| 54 | | PreviewManager.PreviewManagerError; | ||
| 53 | 55 | ||
| 54 | export interface DesktopWindowShape { | 56 | export interface DesktopWindowShape { |
| 55 | readonly createMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>; | 57 | readonly createMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>; |
| ... | @@ -163,6 +165,7 @@ const make = Effect.gen(function* () { | ... | @@ -163,6 +165,7 @@ const make = Effect.gen(function* () { |
| 163 | const electronShell = yield* ElectronShell.ElectronShell; | 165 | const electronShell = yield* ElectronShell.ElectronShell; |
| 164 | const electronTheme = yield* ElectronTheme.ElectronTheme; | 166 | const electronTheme = yield* ElectronTheme.ElectronTheme; |
| 165 | const electronWindow = yield* ElectronWindow.ElectronWindow; | 167 | const electronWindow = yield* ElectronWindow.ElectronWindow; |
| 168 | const previewManager = yield* PreviewManager.PreviewManager; | ||
| 166 | const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | 169 | const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; |
| 167 | const state = yield* DesktopState.DesktopState; | 170 | const state = yield* DesktopState.DesktopState; |
| 168 | const context = yield* Effect.context<DesktopWindowRuntimeServices>(); | 171 | const context = yield* Effect.context<DesktopWindowRuntimeServices>(); |
| ... | @@ -171,7 +174,7 @@ const make = Effect.gen(function* () { | ... | @@ -171,7 +174,7 @@ const make = Effect.gen(function* () { |
| 171 | const createWindow = Effect.fn("desktop.window.createWindow")(function* ( | 174 | const createWindow = Effect.fn("desktop.window.createWindow")(function* ( |
| 172 | backendHttpUrl: URL, | 175 | backendHttpUrl: URL, |
| 173 | ): Effect.fn.Return<Electron.BrowserWindow, DesktopWindowError> { | 176 | ): Effect.fn.Return<Electron.BrowserWindow, DesktopWindowError> { |
| 174 | previewViewManager.getBrowserSession(); | 177 | yield* previewManager.getBrowserSession(); |
| 175 | const applicationUrl = environment.isDevelopment | 178 | const applicationUrl = environment.isDevelopment |
| 176 | ? yield* resolveDesktopDevServerUrl(environment) | 179 | ? yield* resolveDesktopDevServerUrl(environment) |
| 177 | : backendHttpUrl.href; | 180 | : backendHttpUrl.href; |
| ... | @@ -198,11 +201,11 @@ const make = Effect.gen(function* () { | ... | @@ -198,11 +201,11 @@ const make = Effect.gen(function* () { |
| 198 | }, | 201 | }, |
| 199 | }); | 202 | }); |
| 200 | 203 | ||
| 201 | previewViewManager.setMainWindow(window); | 204 | yield* previewManager.setMainWindow(window); |
| 202 | window.webContents.on("will-attach-webview", (event, webPreferences, params) => { | 205 | window.webContents.on("will-attach-webview", (event, webPreferences, params) => { |
| 203 | if ( | 206 | if ( |
| 204 | typeof params.partition !== "string" || | 207 | typeof params.partition !== "string" || |
| 205 | !previewViewManager.isBrowserPartition(params.partition) | 208 | !previewManager.isBrowserPartition(params.partition) |
| 206 | ) { | 209 | ) { |
| 207 | event.preventDefault(); | 210 | event.preventDefault(); |
| 208 | return; | 211 | return; |
apps/server/src/mcp/McpHttpServer.ts+130-110| ... | @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | import * as Sink from "effect/Sink"; | 6 | import * as Sink from "effect/Sink"; |
| 7 | import * as Stream from "effect/Stream"; | 7 | import * as Stream from "effect/Stream"; |
| 8 | import type * as Types from "effect/Types"; | ||
| 8 | import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; | 9 | import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; |
| 9 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | 10 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 10 | 11 | ||
| ... | @@ -29,6 +30,20 @@ const unauthorized = HttpServerResponse.jsonUnsafe( | ... | @@ -29,6 +30,20 @@ const unauthorized = HttpServerResponse.jsonUnsafe( |
| 29 | }, | 30 | }, |
| 30 | ); | 31 | ); |
| 31 | 32 | ||
| 33 | type AuthenticatedHttpEffect = Effect.Effect< | ||
| 34 | HttpServerResponse.HttpServerResponse, | ||
| 35 | Types.unhandled, | ||
| 36 | McpInvocationContext.McpInvocationContext | ||
| 37 | >; | ||
| 38 | |||
| 39 | type McpAuthMiddleware = ( | ||
| 40 | httpEffect: AuthenticatedHttpEffect, | ||
| 41 | ) => Effect.Effect< | ||
| 42 | HttpServerResponse.HttpServerResponse, | ||
| 43 | Types.unhandled, | ||
| 44 | HttpServerRequest.HttpServerRequest | ||
| 45 | >; | ||
| 46 | |||
| 32 | export const normalizeMcpHttpResponse = ( | 47 | export const normalizeMcpHttpResponse = ( |
| 33 | response: HttpServerResponse.HttpServerResponse, | 48 | response: HttpServerResponse.HttpServerResponse, |
| 34 | ): HttpServerResponse.HttpServerResponse => { | 49 | ): HttpServerResponse.HttpServerResponse => { |
| ... | @@ -41,27 +56,123 @@ export const normalizeMcpHttpResponse = ( | ... | @@ -41,27 +56,123 @@ export const normalizeMcpHttpResponse = ( |
| 41 | : response; | 56 | : response; |
| 42 | }; | 57 | }; |
| 43 | 58 | ||
| 59 | const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe( | ||
| 60 | Effect.map( | ||
| 61 | (registry): McpAuthMiddleware => | ||
| 62 | Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { | ||
| 63 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 64 | const authorization = request.headers.authorization; | ||
| 65 | const token = | ||
| 66 | authorization?.startsWith("Bearer ") === true | ||
| 67 | ? authorization.slice("Bearer ".length).trim() | ||
| 68 | : ""; | ||
| 69 | const invocation = yield* registry.resolve(token); | ||
| 70 | if (!invocation) return unauthorized; | ||
| 71 | return yield* httpEffect.pipe( | ||
| 72 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | ||
| 73 | Effect.map(normalizeMcpHttpResponse), | ||
| 74 | ); | ||
| 75 | }), | ||
| 76 | ), | ||
| 77 | Effect.withSpan("McpHttpServer.makeAuthMiddleware"), | ||
| 78 | ); | ||
| 79 | |||
| 44 | const McpAuthMiddlewareLive = HttpRouter.middleware<{ | 80 | const McpAuthMiddlewareLive = HttpRouter.middleware<{ |
| 45 | provides: McpInvocationContext.McpInvocationContext; | 81 | provides: McpInvocationContext.McpInvocationContext; |
| 46 | }>()( | 82 | }>()(makeMcpAuthMiddleware).layer; |
| 47 | Effect.gen(function* () { | 83 | |
| 48 | const registry = yield* McpSessionRegistry.McpSessionRegistry; | 84 | const registerPreviewToolkit = Effect.fn("McpHttpServer.registerPreviewToolkit")(function* () { |
| 49 | return Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { | 85 | const server = yield* McpServer.McpServer; |
| 50 | const request = yield* HttpServerRequest.HttpServerRequest; | 86 | const built = yield* PreviewToolkit; |
| 51 | const authorization = request.headers.authorization; | 87 | const handleTool = built.handle as unknown as ( |
| 52 | const token = | 88 | name: keyof typeof built.tools, |
| 53 | authorization?.startsWith("Bearer ") === true | 89 | payload: unknown, |
| 54 | ? authorization.slice("Bearer ".length).trim() | 90 | ) => Effect.Effect< |
| 55 | : ""; | 91 | Stream.Stream<{ readonly encodedResult: unknown }, Error>, |
| 56 | const invocation = yield* registry.resolve(token); | 92 | Error, |
| 57 | if (!invocation) return unauthorized; | 93 | McpInvocationContext.McpInvocationContext |
| 58 | return yield* httpEffect.pipe( | 94 | >; |
| 59 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | 95 | for (const tool of Object.values(built.tools)) { |
| 60 | Effect.map(normalizeMcpHttpResponse), | 96 | yield* server.addTool({ |
| 61 | ); | 97 | tool: new McpSchema.Tool({ |
| 98 | name: tool.name, | ||
| 99 | description: Tool.getDescription(tool), | ||
| 100 | inputSchema: Tool.getJsonSchema(tool), | ||
| 101 | annotations: { | ||
| 102 | ...Context.getOption(tool.annotations, Tool.Title).pipe( | ||
| 103 | Option.map((title) => ({ title })), | ||
| 104 | Option.getOrUndefined, | ||
| 105 | ), | ||
| 106 | readOnlyHint: Context.get(tool.annotations, Tool.Readonly), | ||
| 107 | destructiveHint: Context.get(tool.annotations, Tool.Destructive), | ||
| 108 | idempotentHint: Context.get(tool.annotations, Tool.Idempotent), | ||
| 109 | openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), | ||
| 110 | }, | ||
| 111 | }), | ||
| 112 | annotations: tool.annotations, | ||
| 113 | handle: (payload) => | ||
| 114 | handleTool(tool.name as keyof typeof built.tools, payload).pipe( | ||
| 115 | Stream.unwrap, | ||
| 116 | Stream.run(Sink.last()), | ||
| 117 | Effect.flatMap(Effect.fromOption), | ||
| 118 | Effect.matchCause({ | ||
| 119 | onFailure: (cause) => | ||
| 120 | new McpSchema.CallToolResult({ | ||
| 121 | isError: true, | ||
| 122 | content: [{ type: "text", text: Cause.pretty(cause) }], | ||
| 123 | }), | ||
| 124 | onSuccess: (result) => { | ||
| 125 | if (tool.name === "preview_snapshot") { | ||
| 126 | const snapshot = result.encodedResult as { | ||
| 127 | readonly screenshot: { | ||
| 128 | readonly mimeType: "image/png"; | ||
| 129 | readonly data: string; | ||
| 130 | readonly width: number; | ||
| 131 | readonly height: number; | ||
| 132 | }; | ||
| 133 | readonly [key: string]: unknown; | ||
| 134 | }; | ||
| 135 | const { screenshot, ...page } = snapshot; | ||
| 136 | const metadata = { | ||
| 137 | ...page, | ||
| 138 | screenshot: { | ||
| 139 | mimeType: screenshot.mimeType, | ||
| 140 | width: screenshot.width, | ||
| 141 | height: screenshot.height, | ||
| 142 | }, | ||
| 143 | }; | ||
| 144 | return new McpSchema.CallToolResult({ | ||
| 145 | isError: false, | ||
| 146 | structuredContent: metadata, | ||
| 147 | content: [ | ||
| 148 | { type: "text", text: JSON.stringify(metadata) }, | ||
| 149 | { | ||
| 150 | type: "image", | ||
| 151 | data: new Uint8Array(Buffer.from(screenshot.data, "base64")), | ||
| 152 | mimeType: screenshot.mimeType, | ||
| 153 | }, | ||
| 154 | ], | ||
| 155 | }); | ||
| 156 | } | ||
| 157 | const encodedResultText = JSON.stringify(result.encodedResult) ?? "null"; | ||
| 158 | return new McpSchema.CallToolResult({ | ||
| 159 | isError: false, | ||
| 160 | structuredContent: | ||
| 161 | result.encodedResult !== null && typeof result.encodedResult === "object" | ||
| 162 | ? result.encodedResult | ||
| 163 | : undefined, | ||
| 164 | content: [{ type: "text", text: encodedResultText }], | ||
| 165 | }); | ||
| 166 | }, | ||
| 167 | }), | ||
| 168 | ) as unknown as Effect.Effect<McpSchema.CallToolResult, never, McpSchema.McpServerClient>, | ||
| 62 | }); | 169 | }); |
| 63 | }), | 170 | } |
| 64 | ).layer; | 171 | }); |
| 172 | |||
| 173 | export const PreviewToolkitRegistrationLive = Layer.effectDiscard(registerPreviewToolkit()).pipe( | ||
| 174 | Layer.provide(PreviewToolkitHandlersLive), | ||
| 175 | ); | ||
| 65 | 176 | ||
| 66 | const McpTransportLive = McpServer.layerHttp({ | 177 | const McpTransportLive = McpServer.layerHttp({ |
| 67 | name: "T3 Code", | 178 | name: "T3 Code", |
| ... | @@ -69,98 +180,7 @@ const McpTransportLive = McpServer.layerHttp({ | ... | @@ -69,98 +180,7 @@ const McpTransportLive = McpServer.layerHttp({ |
| 69 | path: "/mcp", | 180 | path: "/mcp", |
| 70 | }).pipe(Layer.provide(McpAuthMiddlewareLive)); | 181 | }).pipe(Layer.provide(McpAuthMiddlewareLive)); |
| 71 | 182 | ||
| 72 | export const PreviewToolkitRegistrationLive = Layer.effectDiscard( | 183 | export const layer = PreviewToolkitRegistrationLive.pipe( |
| 73 | Effect.gen(function* () { | ||
| 74 | const server = yield* McpServer.McpServer; | ||
| 75 | const built = yield* PreviewToolkit; | ||
| 76 | const handleTool = built.handle as unknown as ( | ||
| 77 | name: keyof typeof built.tools, | ||
| 78 | payload: unknown, | ||
| 79 | ) => Effect.Effect< | ||
| 80 | Stream.Stream<{ readonly encodedResult: unknown }, Error>, | ||
| 81 | Error, | ||
| 82 | McpInvocationContext.McpInvocationContext | ||
| 83 | >; | ||
| 84 | for (const tool of Object.values(built.tools)) { | ||
| 85 | yield* server.addTool({ | ||
| 86 | tool: new McpSchema.Tool({ | ||
| 87 | name: tool.name, | ||
| 88 | description: Tool.getDescription(tool), | ||
| 89 | inputSchema: Tool.getJsonSchema(tool), | ||
| 90 | annotations: { | ||
| 91 | ...Context.getOption(tool.annotations, Tool.Title).pipe( | ||
| 92 | Option.map((title) => ({ title })), | ||
| 93 | Option.getOrUndefined, | ||
| 94 | ), | ||
| 95 | readOnlyHint: Context.get(tool.annotations, Tool.Readonly), | ||
| 96 | destructiveHint: Context.get(tool.annotations, Tool.Destructive), | ||
| 97 | idempotentHint: Context.get(tool.annotations, Tool.Idempotent), | ||
| 98 | openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), | ||
| 99 | }, | ||
| 100 | }), | ||
| 101 | annotations: tool.annotations, | ||
| 102 | handle: (payload) => | ||
| 103 | handleTool(tool.name as keyof typeof built.tools, payload).pipe( | ||
| 104 | Stream.unwrap, | ||
| 105 | Stream.run(Sink.last()), | ||
| 106 | Effect.flatMap(Effect.fromOption), | ||
| 107 | Effect.matchCause({ | ||
| 108 | onFailure: (cause) => | ||
| 109 | new McpSchema.CallToolResult({ | ||
| 110 | isError: true, | ||
| 111 | content: [{ type: "text", text: Cause.pretty(cause) }], | ||
| 112 | }), | ||
| 113 | onSuccess: (result) => { | ||
| 114 | if (tool.name === "preview_snapshot") { | ||
| 115 | const snapshot = result.encodedResult as { | ||
| 116 | readonly screenshot: { | ||
| 117 | readonly mimeType: "image/png"; | ||
| 118 | readonly data: string; | ||
| 119 | readonly width: number; | ||
| 120 | readonly height: number; | ||
| 121 | }; | ||
| 122 | readonly [key: string]: unknown; | ||
| 123 | }; | ||
| 124 | const { screenshot, ...page } = snapshot; | ||
| 125 | const metadata = { | ||
| 126 | ...page, | ||
| 127 | screenshot: { | ||
| 128 | mimeType: screenshot.mimeType, | ||
| 129 | width: screenshot.width, | ||
| 130 | height: screenshot.height, | ||
| 131 | }, | ||
| 132 | }; | ||
| 133 | return new McpSchema.CallToolResult({ | ||
| 134 | isError: false, | ||
| 135 | structuredContent: metadata, | ||
| 136 | content: [ | ||
| 137 | { type: "text", text: JSON.stringify(metadata) }, | ||
| 138 | { | ||
| 139 | type: "image", | ||
| 140 | data: new Uint8Array(Buffer.from(screenshot.data, "base64")), | ||
| 141 | mimeType: screenshot.mimeType, | ||
| 142 | }, | ||
| 143 | ], | ||
| 144 | }); | ||
| 145 | } | ||
| 146 | const encodedResultText = JSON.stringify(result.encodedResult) ?? "null"; | ||
| 147 | return new McpSchema.CallToolResult({ | ||
| 148 | isError: false, | ||
| 149 | structuredContent: | ||
| 150 | result.encodedResult !== null && typeof result.encodedResult === "object" | ||
| 151 | ? result.encodedResult | ||
| 152 | : undefined, | ||
| 153 | content: [{ type: "text", text: encodedResultText }], | ||
| 154 | }); | ||
| 155 | }, | ||
| 156 | }), | ||
| 157 | ) as unknown as Effect.Effect<McpSchema.CallToolResult, never, McpSchema.McpServerClient>, | ||
| 158 | }); | ||
| 159 | } | ||
| 160 | }), | ||
| 161 | ).pipe(Layer.provide(PreviewToolkitHandlersLive)); | ||
| 162 | |||
| 163 | export const layer = Layer.mergeAll(PreviewToolkitRegistrationLive).pipe( | ||
| 164 | Layer.provideMerge(McpTransportLive), | 184 | Layer.provideMerge(McpTransportLive), |
| 165 | Layer.provide(PreviewAutomationBroker.layer), | 185 | Layer.provide(PreviewAutomationBroker.layer), |
| 166 | ); | 186 | ); |
apps/server/src/mcp/McpSessionRegistry.test.ts+11-9| ... | @@ -18,15 +18,17 @@ const fakeEnvironment = ServerEnvironment.of({ | ... | @@ -18,15 +18,17 @@ const fakeEnvironment = ServerEnvironment.of({ |
| 18 | }); | 18 | }); |
| 19 | 19 | ||
| 20 | const makeRegistry = (now: () => number) => | 20 | const makeRegistry = (now: () => number) => |
| 21 | McpSessionRegistry.makeForTest({ | 21 | McpSessionRegistry.__testing |
| 22 | now, | 22 | .make({ |
| 23 | idleTimeoutMs: 100, | 23 | now, |
| 24 | maximumLifetimeMs: 1_000, | 24 | idleTimeoutMs: 100, |
| 25 | }).pipe( | 25 | maximumLifetimeMs: 1_000, |
| 26 | Effect.provideService(HttpServer.HttpServer, fakeHttpServer), | 26 | }) |
| 27 | Effect.provideService(ServerEnvironment, fakeEnvironment), | 27 | .pipe( |
| 28 | Effect.provide(NodeServices.layer), | 28 | Effect.provideService(HttpServer.HttpServer, fakeHttpServer), |
| 29 | ); | 29 | Effect.provideService(ServerEnvironment, fakeEnvironment), |
| 30 | Effect.provide(NodeServices.layer), | ||
| 31 | ); | ||
| 30 | 32 | ||
| 31 | it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () => | 33 | it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () => |
| 32 | Effect.gen(function* () { | 34 | Effect.gen(function* () { |
apps/server/src/mcp/McpSessionRegistry.ts+5-2| ... | @@ -184,8 +184,6 @@ export const layer: Layer.Layer< | ... | @@ -184,8 +184,6 @@ export const layer: Layer.Layer< |
| 184 | ), | 184 | ), |
| 185 | ); | 185 | ); |
| 186 | 186 | ||
| 187 | export const makeForTest = make; | ||
| 188 | |||
| 189 | export const issueActiveMcpCredential = ( | 187 | export const issueActiveMcpCredential = ( |
| 190 | request: McpCredentialRequest, | 188 | request: McpCredentialRequest, |
| 191 | ): Effect.Effect<McpIssuedCredential | undefined> => | 189 | ): Effect.Effect<McpIssuedCredential | undefined> => |
| ... | @@ -200,3 +198,8 @@ export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> = | ... | @@ -200,3 +198,8 @@ export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> = |
| 200 | 198 | ||
| 201 | export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> => | 199 | export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> => |
| 202 | activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; | 200 | activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; |
| 201 | |||
| 202 | /** Exposed for tests. */ | ||
| 203 | export const __testing = { | ||
| 204 | make, | ||
| 205 | }; |
apps/server/src/mcp/PreviewAutomationBroker.test.ts+3-3| ... | @@ -23,7 +23,7 @@ const scope = { | ... | @@ -23,7 +23,7 @@ const scope = { |
| 23 | it.effect("routes a request to the focused owner and correlates its response", () => | 23 | it.effect("routes a request to the focused owner and correlates its response", () => |
| 24 | Effect.scoped( | 24 | Effect.scoped( |
| 25 | Effect.gen(function* () { | 25 | Effect.gen(function* () { |
| 26 | const broker = yield* PreviewAutomationBroker.makeForTest; | 26 | const broker = yield* PreviewAutomationBroker.__testing.make(); |
| 27 | const requests = yield* broker.connect("client-1"); | 27 | const requests = yield* broker.connect("client-1"); |
| 28 | yield* Stream.runForEach(requests, (request) => | 28 | yield* Stream.runForEach(requests, (request) => |
| 29 | broker.respond({ | 29 | broker.respond({ |
| ... | @@ -56,7 +56,7 @@ it.effect("routes a request to the focused owner and correlates its response", ( | ... | @@ -56,7 +56,7 @@ it.effect("routes a request to the focused owner and correlates its response", ( |
| 56 | 56 | ||
| 57 | it.effect("rejects calls when no focused owner exists", () => | 57 | it.effect("rejects calls when no focused owner exists", () => |
| 58 | Effect.gen(function* () { | 58 | Effect.gen(function* () { |
| 59 | const broker = yield* PreviewAutomationBroker.makeForTest; | 59 | const broker = yield* PreviewAutomationBroker.__testing.make(); |
| 60 | const error = yield* broker | 60 | const error = yield* broker |
| 61 | .invoke<void>({ scope, operation: "status", input: {} }) | 61 | .invoke<void>({ scope, operation: "status", input: {} }) |
| 62 | .pipe(Effect.flip); | 62 | .pipe(Effect.flip); |
| ... | @@ -67,7 +67,7 @@ it.effect("rejects calls when no focused owner exists", () => | ... | @@ -67,7 +67,7 @@ it.effect("rejects calls when no focused owner exists", () => |
| 67 | it.effect("routes interactive commands to a hidden durable browser host", () => | 67 | it.effect("routes interactive commands to a hidden durable browser host", () => |
| 68 | Effect.scoped( | 68 | Effect.scoped( |
| 69 | Effect.gen(function* () { | 69 | Effect.gen(function* () { |
| 70 | const broker = yield* PreviewAutomationBroker.makeForTest; | 70 | const broker = yield* PreviewAutomationBroker.__testing.make(); |
| 71 | const requests = yield* broker.connect("client-hidden"); | 71 | const requests = yield* broker.connect("client-hidden"); |
| 72 | yield* Stream.runForEach(requests, (request) => | 72 | yield* Stream.runForEach(requests, (request) => |
| 73 | broker.respond({ requestId: request.requestId, ok: true }), | 73 | broker.respond({ requestId: request.requestId, ok: true }), |
apps/server/src/mcp/PreviewAutomationBroker.ts+12-13| ... | @@ -119,14 +119,12 @@ const makeResponseError = ( | ... | @@ -119,14 +119,12 @@ const makeResponseError = ( |
| 119 | } | 119 | } |
| 120 | }; | 120 | }; |
| 121 | 121 | ||
| 122 | const makeService = (): PreviewAutomationBrokerShape => { | 122 | const make = Effect.fn("PreviewAutomationBroker.make")(function* () { |
| 123 | const state = Effect.runSync( | 123 | const state = yield* Ref.make<BrokerState>({ |
| 124 | Ref.make<BrokerState>({ | 124 | clients: new Map(), |
| 125 | clients: new Map(), | 125 | owners: new Map(), |
| 126 | owners: new Map(), | 126 | pending: new Map(), |
| 127 | pending: new Map(), | 127 | }); |
| 128 | }), | ||
| 129 | ); | ||
| 130 | let requestSequence = 0; | 128 | let requestSequence = 0; |
| 131 | 129 | ||
| 132 | const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( | 130 | const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( |
| ... | @@ -297,10 +295,11 @@ const makeService = (): PreviewAutomationBrokerShape => { | ... | @@ -297,10 +295,11 @@ const makeService = (): PreviewAutomationBrokerShape => { |
| 297 | }); | 295 | }); |
| 298 | 296 | ||
| 299 | return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); | 297 | return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); |
| 300 | }; | 298 | }); |
| 301 | 299 | ||
| 302 | const service = makeService(); | 300 | export const layer = Layer.effect(PreviewAutomationBroker, make()); |
| 303 | const make = Effect.succeed(service); | ||
| 304 | export const layer = Layer.effect(PreviewAutomationBroker, make); | ||
| 305 | 301 | ||
| 306 | export const makeForTest = Effect.sync(makeService); | 302 | /** Exposed for tests. */ |
| 303 | export const __testing = { | ||
| 304 | make, | ||
| 305 | }; |
apps/server/src/preview/Manager.ts+2-2| ... | @@ -127,7 +127,7 @@ const buildIdleSnapshot = (input: { | ... | @@ -127,7 +127,7 @@ const buildIdleSnapshot = (input: { |
| 127 | updatedAt: input.updatedAt, | 127 | updatedAt: input.updatedAt, |
| 128 | }); | 128 | }); |
| 129 | 129 | ||
| 130 | const make = Effect.gen(function* () { | 130 | const make = Effect.fn("PreviewManager.make")(function* () { |
| 131 | const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState); | 131 | const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState); |
| 132 | // Unbounded PubSub is fine here — events are tiny and we don't want to | 132 | // Unbounded PubSub is fine here — events are tiny and we don't want to |
| 133 | // block publishers if a subscriber is slow. WS clients backpressure on | 133 | // block publishers if a subscriber is slow. WS clients backpressure on |
| ... | @@ -359,4 +359,4 @@ const make = Effect.gen(function* () { | ... | @@ -359,4 +359,4 @@ const make = Effect.gen(function* () { |
| 359 | } satisfies PreviewManagerShape; | 359 | } satisfies PreviewManagerShape; |
| 360 | }); | 360 | }); |
| 361 | 361 | ||
| 362 | export const layer = Layer.effect(PreviewManager, make); | 362 | export const layer = Layer.effect(PreviewManager, make()); |
apps/server/src/preview/PortScanner.test.ts+71-42| ... | @@ -2,8 +2,9 @@ import * as net from "node:net"; | ... | @@ -2,8 +2,9 @@ import * as net from "node:net"; |
| 2 | 2 | ||
| 3 | import { it as effectIt } from "@effect/vitest"; | 3 | import { it as effectIt } from "@effect/vitest"; |
| 4 | import { ThreadId } from "@t3tools/contracts"; | 4 | import { ThreadId } from "@t3tools/contracts"; |
| 5 | import * as Net from "@t3tools/shared/Net"; | ||
| 5 | import { Effect, Layer } from "effect"; | 6 | import { Effect, Layer } from "effect"; |
| 6 | import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; | 7 | import { describe, expect, it } from "vite-plus/test"; |
| 7 | 8 | ||
| 8 | import { ProcessRunner } from "../processRunner.ts"; | 9 | import { ProcessRunner } from "../processRunner.ts"; |
| 9 | import * as PortScanner from "./PortScanner.ts"; | 10 | import * as PortScanner from "./PortScanner.ts"; |
| ... | @@ -13,7 +14,60 @@ const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serv | ... | @@ -13,7 +14,60 @@ const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serv |
| 13 | const TestProcessRunner = Layer.succeed(ProcessRunner, { | 14 | const TestProcessRunner = Layer.succeed(ProcessRunner, { |
| 14 | run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), | 15 | run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), |
| 15 | }); | 16 | }); |
| 16 | const TestPortDiscoveryLive = PortScanner.layer.pipe(Layer.provide(TestProcessRunner)); | 17 | const TestPortDiscoveryLive = PortScanner.layer.pipe( |
| 18 | Layer.provide(Layer.mergeAll(TestProcessRunner, Net.layer)), | ||
| 19 | ); | ||
| 20 | |||
| 21 | const openServer = (port: number): Effect.Effect<net.Server | null> => | ||
| 22 | Effect.callback((resume) => { | ||
| 23 | const server = net.createServer(); | ||
| 24 | server.once("error", () => { | ||
| 25 | resume(Effect.succeed(null)); | ||
| 26 | }); | ||
| 27 | server.listen(port, "127.0.0.1", () => { | ||
| 28 | resume(Effect.succeed(server)); | ||
| 29 | }); | ||
| 30 | return Effect.sync(() => { | ||
| 31 | server.close(); | ||
| 32 | }); | ||
| 33 | }); | ||
| 34 | |||
| 35 | const closeServer = (server: net.Server): Effect.Effect<void> => | ||
| 36 | Effect.callback((resume) => { | ||
| 37 | server.close(() => resume(Effect.void)); | ||
| 38 | }); | ||
| 39 | |||
| 40 | const windowsPlatform = Effect.acquireRelease( | ||
| 41 | Effect.sync(() => { | ||
| 42 | const originalPlatform = process.platform; | ||
| 43 | Object.defineProperty(process, "platform", { value: "win32", configurable: true }); | ||
| 44 | return originalPlatform; | ||
| 45 | }), | ||
| 46 | (originalPlatform) => | ||
| 47 | Effect.sync(() => { | ||
| 48 | Object.defineProperty(process, "platform", { | ||
| 49 | value: originalPlatform, | ||
| 50 | configurable: true, | ||
| 51 | }); | ||
| 52 | }), | ||
| 53 | ); | ||
| 54 | |||
| 55 | const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( | ||
| 56 | ports: ReadonlyArray<number>, | ||
| 57 | ) { | ||
| 58 | for (const port of ports) { | ||
| 59 | const server = yield* openServer(port); | ||
| 60 | if (server !== null) return { port, server }; | ||
| 61 | } | ||
| 62 | return yield* Effect.die( | ||
| 63 | new Error("No common development port was available for the preview scanner test"), | ||
| 64 | ); | ||
| 65 | }); | ||
| 66 | |||
| 67 | const commonDevServer = Effect.acquireRelease( | ||
| 68 | openCommonDevServer(PortScanner.COMMON_DEV_PORTS), | ||
| 69 | ({ server }) => closeServer(server), | ||
| 70 | ); | ||
| 17 | 71 | ||
| 18 | describe("parsePortFromLsofName", () => { | 72 | describe("parsePortFromLsofName", () => { |
| 19 | it("parses *:port", () => { | 73 | it("parses *:port", () => { |
| ... | @@ -180,51 +234,26 @@ describe("parseWindowsListenerOutput", () => { | ... | @@ -180,51 +234,26 @@ describe("parseWindowsListenerOutput", () => { |
| 180 | * path (TCP-probe fallback) by monkey-patching `process.platform` for the | 234 | * path (TCP-probe fallback) by monkey-patching `process.platform` for the |
| 181 | * duration of the test so we don't depend on `lsof` being installed. | 235 | * duration of the test so we don't depend on `lsof` being installed. |
| 182 | */ | 236 | */ |
| 183 | describe("PortDiscovery integration (TCP probe fallback)", () => { | 237 | effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => { |
| 184 | let originalPlatform: NodeJS.Platform; | 238 | it.effect( |
| 185 | let server: net.Server; | 239 | "scan() returns a server we just opened on a curated dev port", |
| 186 | let port: number; | 240 | Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { |
| 187 | 241 | yield* windowsPlatform; | |
| 188 | beforeEach(async () => { | 242 | const { port } = yield* commonDevServer; |
| 189 | originalPlatform = process.platform; | ||
| 190 | Object.defineProperty(process, "platform", { value: "win32", configurable: true }); | ||
| 191 | for (const candidate of PortScanner.COMMON_DEV_PORTS) { | ||
| 192 | const candidateServer = net.createServer(); | ||
| 193 | const listening = await new Promise<boolean>((resolve) => { | ||
| 194 | candidateServer.once("error", () => resolve(false)); | ||
| 195 | candidateServer.listen(candidate, "127.0.0.1", () => resolve(true)); | ||
| 196 | }); | ||
| 197 | if (listening) { | ||
| 198 | server = candidateServer; | ||
| 199 | port = candidate; | ||
| 200 | return; | ||
| 201 | } | ||
| 202 | candidateServer.close(); | ||
| 203 | } | ||
| 204 | throw new Error("No common development port was available for the preview scanner test"); | ||
| 205 | }); | ||
| 206 | |||
| 207 | afterEach(async () => { | ||
| 208 | Object.defineProperty(process, "platform", { | ||
| 209 | value: originalPlatform, | ||
| 210 | configurable: true, | ||
| 211 | }); | ||
| 212 | await new Promise<void>((resolve) => server.close(() => resolve())); | ||
| 213 | }); | ||
| 214 | |||
| 215 | effectIt.effect("scan() returns a server we just opened on a curated dev port", () => | ||
| 216 | Effect.gen(function* () { | ||
| 217 | const scanner = yield* PortScanner.PortDiscovery; | 243 | const scanner = yield* PortScanner.PortDiscovery; |
| 218 | const result = yield* scanner.scan(); | 244 | const result = yield* scanner.scan(); |
| 219 | const found = result.find((server) => server.port === port); | 245 | const found = result.find((server) => server.port === port); |
| 220 | expect(found).toBeDefined(); | 246 | expect(found).toBeDefined(); |
| 221 | expect(found?.host).toBe("localhost"); | 247 | expect(found?.host).toBe("localhost"); |
| 222 | }).pipe(Effect.provide(TestPortDiscoveryLive)), | 248 | }), |
| 223 | ); | 249 | ); |
| 224 | 250 | ||
| 225 | effectIt.effect("retain() drives an immediate broadcast to subscribers", () => { | 251 | it.effect( |
| 226 | const received: number[] = []; | 252 | "retain() drives an immediate broadcast to subscribers", |
| 227 | return Effect.gen(function* () { | 253 | Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () { |
| 254 | yield* windowsPlatform; | ||
| 255 | const { port } = yield* commonDevServer; | ||
| 256 | const received: number[] = []; | ||
| 228 | const scanner = yield* PortScanner.PortDiscovery; | 257 | const scanner = yield* PortScanner.PortDiscovery; |
| 229 | const unsubscribe = yield* scanner.subscribe((servers) => | 258 | const unsubscribe = yield* scanner.subscribe((servers) => |
| 230 | Effect.sync(() => { | 259 | Effect.sync(() => { |
| ... | @@ -235,6 +264,6 @@ describe("PortDiscovery integration (TCP probe fallback)", () => { | ... | @@ -235,6 +264,6 @@ describe("PortDiscovery integration (TCP probe fallback)", () => { |
| 235 | unsubscribe(); | 264 | unsubscribe(); |
| 236 | release(); | 265 | release(); |
| 237 | expect(received).toContain(port); | 266 | expect(received).toContain(port); |
| 238 | }).pipe(Effect.provide(TestPortDiscoveryLive)); | 267 | }), |
| 239 | }); | 268 | ); |
| 240 | }); | 269 | }); |
apps/server/src/preview/PortScanner.ts+41-51| ... | @@ -5,15 +5,14 @@ | ... | @@ -5,15 +5,14 @@ |
| 5 | * stable line-prefixed field format; this is the only `lsof` flag set we rely | 5 | * stable line-prefixed field format; this is the only `lsof` flag set we rely |
| 6 | * on). | 6 | * on). |
| 7 | * | 7 | * |
| 8 | * Windows / lsof missing: TCP-connects to a curated list of common dev ports | 8 | * Windows / lsof missing: checks a curated list of common dev ports through |
| 9 | * on 127.0.0.1. | 9 | * the shared Net service. |
| 10 | * | 10 | * |
| 11 | * Polling is reference-counted via `retain()`. A single layer-scoped fiber | 11 | * Polling is reference-counted via `retain()`. A single layer-scoped fiber |
| 12 | * polls forever, but each tick is a no-op when the retain count is zero. | 12 | * polls forever, but each tick is a no-op when the retain count is zero. |
| 13 | */ | 13 | */ |
| 14 | import * as net from "node:net"; | ||
| 15 | |||
| 16 | import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; | 14 | import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; |
| 15 | import * as Net from "@t3tools/shared/Net"; | ||
| 17 | import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; | 16 | import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; |
| 18 | import { Cause, Context, Duration, Effect, Layer, Ref, Schedule } from "effect"; | 17 | import { Cause, Context, Duration, Effect, Layer, Ref, Schedule } from "effect"; |
| 19 | 18 | ||
| ... | @@ -45,7 +44,6 @@ export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([ | ... | @@ -45,7 +44,6 @@ export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([ |
| 45 | ]); | 44 | ]); |
| 46 | 45 | ||
| 47 | const POLL_INTERVAL = Duration.seconds(3); | 46 | const POLL_INTERVAL = Duration.seconds(3); |
| 48 | const TCP_PROBE_TIMEOUT_MS = 200; | ||
| 49 | const LSOF_TIMEOUT_MS = 5_000; | 47 | const LSOF_TIMEOUT_MS = 5_000; |
| 50 | const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; | 48 | const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; |
| 51 | 49 | ||
| ... | @@ -148,40 +146,6 @@ const parseWindowsListenerOutput = ( | ... | @@ -148,40 +146,6 @@ const parseWindowsListenerOutput = ( |
| 148 | return [...seen.values()].toSorted((left, right) => left.port - right.port); | 146 | return [...seen.values()].toSorted((left, right) => left.port - right.port); |
| 149 | }; | 147 | }; |
| 150 | 148 | ||
| 151 | const probeTcpPort = (port: number): Promise<boolean> => | ||
| 152 | new Promise((resolve) => { | ||
| 153 | const socket = new net.Socket(); | ||
| 154 | let settled = false; | ||
| 155 | const finish = (ok: boolean) => { | ||
| 156 | if (settled) return; | ||
| 157 | settled = true; | ||
| 158 | socket.destroy(); | ||
| 159 | resolve(ok); | ||
| 160 | }; | ||
| 161 | socket.setTimeout(TCP_PROBE_TIMEOUT_MS); | ||
| 162 | socket.once("timeout", () => finish(false)); | ||
| 163 | socket.once("error", () => finish(false)); | ||
| 164 | socket.once("connect", () => finish(true)); | ||
| 165 | socket.connect({ host: "127.0.0.1", port }); | ||
| 166 | }); | ||
| 167 | |||
| 168 | const probeCommonPorts = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> => | ||
| 169 | Effect.promise(async () => { | ||
| 170 | const results = await Promise.all( | ||
| 171 | COMMON_DEV_PORTS.map(async (port) => ({ port, listening: await probeTcpPort(port) })), | ||
| 172 | ); | ||
| 173 | return results | ||
| 174 | .filter((r) => r.listening) | ||
| 175 | .map<DiscoveredLocalServer>((r) => ({ | ||
| 176 | host: "localhost", | ||
| 177 | port: r.port, | ||
| 178 | url: `http://localhost:${r.port}`, | ||
| 179 | processName: null, | ||
| 180 | pid: null, | ||
| 181 | terminal: null, | ||
| 182 | })); | ||
| 183 | }); | ||
| 184 | |||
| 185 | const serversEqual = ( | 149 | const serversEqual = ( |
| 186 | left: ReadonlyArray<DiscoveredLocalServer>, | 150 | left: ReadonlyArray<DiscoveredLocalServer>, |
| 187 | right: ReadonlyArray<DiscoveredLocalServer>, | 151 | right: ReadonlyArray<DiscoveredLocalServer>, |
| ... | @@ -206,7 +170,8 @@ const serversEqual = ( | ... | @@ -206,7 +170,8 @@ const serversEqual = ( |
| 206 | return true; | 170 | return true; |
| 207 | }; | 171 | }; |
| 208 | 172 | ||
| 209 | const make = Effect.gen(function* () { | 173 | const make = Effect.fn("PortDiscovery.make")(function* () { |
| 174 | const net = yield* Net.NetService; | ||
| 210 | const processRunner = yield* ProcessRunner; | 175 | const processRunner = yield* ProcessRunner; |
| 211 | const stateRef = yield* Ref.make<ScannerState>({ | 176 | const stateRef = yield* Ref.make<ScannerState>({ |
| 212 | lastSnapshot: [], | 177 | lastSnapshot: [], |
| ... | @@ -224,6 +189,30 @@ const make = Effect.gen(function* () { | ... | @@ -224,6 +189,30 @@ const make = Effect.gen(function* () { |
| 224 | // be a synchronous side-effect-only function. | 189 | // be a synchronous side-effect-only function. |
| 225 | let retainCount = 0; | 190 | let retainCount = 0; |
| 226 | 191 | ||
| 192 | const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { | ||
| 193 | const results = yield* Effect.forEach( | ||
| 194 | COMMON_DEV_PORTS, | ||
| 195 | (port) => | ||
| 196 | net.isPortAvailableOnLoopback(port).pipe( | ||
| 197 | Effect.map((available) => ({ | ||
| 198 | port, | ||
| 199 | listening: !available, | ||
| 200 | })), | ||
| 201 | ), | ||
| 202 | { concurrency: "unbounded" }, | ||
| 203 | ); | ||
| 204 | return results | ||
| 205 | .filter((result) => result.listening) | ||
| 206 | .map<DiscoveredLocalServer>((result) => ({ | ||
| 207 | host: "localhost", | ||
| 208 | port: result.port, | ||
| 209 | url: `http://localhost:${result.port}`, | ||
| 210 | processName: null, | ||
| 211 | pid: null, | ||
| 212 | terminal: null, | ||
| 213 | })); | ||
| 214 | }); | ||
| 215 | |||
| 227 | const scanOnce = Effect.fn("PortDiscovery.scan")(function* () { | 216 | const scanOnce = Effect.fn("PortDiscovery.scan")(function* () { |
| 228 | const terminalByProcessId = new Map<number, TerminalProcessOwner>(); | 217 | const terminalByProcessId = new Map<number, TerminalProcessOwner>(); |
| 229 | for (const registration of terminalProcesses.values()) { | 218 | for (const registration of terminalProcesses.values()) { |
| ... | @@ -268,14 +257,15 @@ const make = Effect.gen(function* () { | ... | @@ -268,14 +257,15 @@ const make = Effect.gen(function* () { |
| 268 | const broadcast = (servers: ReadonlyArray<DiscoveredLocalServer>): Effect.Effect<void> => | 257 | const broadcast = (servers: ReadonlyArray<DiscoveredLocalServer>): Effect.Effect<void> => |
| 269 | Effect.forEach(Array.from(listeners), (listener) => listener(servers), { discard: true }); | 258 | Effect.forEach(Array.from(listeners), (listener) => listener(servers), { discard: true }); |
| 270 | 259 | ||
| 271 | const pollTick = Effect.gen(function* () { | 260 | const pollTick = Effect.fn("PortDiscovery.pollTick")( |
| 272 | if (retainCount <= 0) return; | 261 | function* () { |
| 273 | const next = yield* scanOnce(); | 262 | if (retainCount <= 0) return; |
| 274 | const state = yield* Ref.get(stateRef); | 263 | const next = yield* scanOnce(); |
| 275 | if (serversEqual(state.lastSnapshot, next)) return; | 264 | const state = yield* Ref.get(stateRef); |
| 276 | yield* Ref.update(stateRef, (s) => ({ ...s, lastSnapshot: next })); | 265 | if (serversEqual(state.lastSnapshot, next)) return; |
| 277 | yield* broadcast(next); | 266 | yield* Ref.update(stateRef, (s) => ({ ...s, lastSnapshot: next })); |
| 278 | }).pipe( | 267 | yield* broadcast(next); |
| 268 | }, | ||
| 279 | Effect.catchCause((cause: Cause.Cause<never>) => | 269 | Effect.catchCause((cause: Cause.Cause<never>) => |
| 280 | Effect.logWarning("preview port scan failed", Cause.pretty(cause)), | 270 | Effect.logWarning("preview port scan failed", Cause.pretty(cause)), |
| 281 | ), | 271 | ), |
| ... | @@ -283,7 +273,7 @@ const make = Effect.gen(function* () { | ... | @@ -283,7 +273,7 @@ const make = Effect.gen(function* () { |
| 283 | 273 | ||
| 284 | // Single layer-scoped polling fiber. Ticks are no-ops when no client is | 274 | // Single layer-scoped polling fiber. Ticks are no-ops when no client is |
| 285 | // currently retained, so the cost is one Ref.get every POLL_INTERVAL. | 275 | // currently retained, so the cost is one Ref.get every POLL_INTERVAL. |
| 286 | yield* Effect.forkScoped(pollTick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); | 276 | yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); |
| 287 | 277 | ||
| 288 | const retain: PortDiscoveryShape["retain"] = Effect.fn("PortDiscovery.retain")(function* () { | 278 | const retain: PortDiscoveryShape["retain"] = Effect.fn("PortDiscovery.retain")(function* () { |
| 289 | const wasIdle = retainCount === 0; | 279 | const wasIdle = retainCount === 0; |
| ... | @@ -291,7 +281,7 @@ const make = Effect.gen(function* () { | ... | @@ -291,7 +281,7 @@ const make = Effect.gen(function* () { |
| 291 | if (wasIdle) { | 281 | if (wasIdle) { |
| 292 | // Run an immediate scan + broadcast so the new retainer doesn't have | 282 | // Run an immediate scan + broadcast so the new retainer doesn't have |
| 293 | // to wait up to POLL_INTERVAL for the first emission. | 283 | // to wait up to POLL_INTERVAL for the first emission. |
| 294 | yield* pollTick; | 284 | yield* pollTick(); |
| 295 | } | 285 | } |
| 296 | let released = false; | 286 | let released = false; |
| 297 | return () => { | 287 | return () => { |
| ... | @@ -349,7 +339,7 @@ const make = Effect.gen(function* () { | ... | @@ -349,7 +339,7 @@ const make = Effect.gen(function* () { |
| 349 | } satisfies PortDiscoveryShape; | 339 | } satisfies PortDiscoveryShape; |
| 350 | }); | 340 | }); |
| 351 | 341 | ||
| 352 | export const layer = Layer.effect(PortDiscovery, make); | 342 | export const layer = Layer.effect(PortDiscovery, make()); |
| 353 | 343 | ||
| 354 | /** Exposed for tests. */ | 344 | /** Exposed for tests. */ |
| 355 | export const __testing = { | 345 | export const __testing = { |
packages/contracts/src/ipc.ts+252-1| ... | @@ -66,7 +66,7 @@ import type { | ... | @@ -66,7 +66,7 @@ import type { |
| 66 | PreviewReportStatusInput, | 66 | PreviewReportStatusInput, |
| 67 | PreviewSessionSnapshot, | 67 | PreviewSessionSnapshot, |
| 68 | } from "./preview.ts"; | 68 | } from "./preview.ts"; |
| 69 | import type { | 69 | import { |
| 70 | PreviewAutomationClickInput, | 70 | PreviewAutomationClickInput, |
| 71 | PreviewAutomationEvaluateInput, | 71 | PreviewAutomationEvaluateInput, |
| 72 | PreviewAutomationOwner, | 72 | PreviewAutomationOwner, |
| ... | @@ -455,6 +455,42 @@ export interface DesktopPreviewTabState { | ... | @@ -455,6 +455,42 @@ export interface DesktopPreviewTabState { |
| 455 | updatedAt: string; | 455 | updatedAt: string; |
| 456 | } | 456 | } |
| 457 | 457 | ||
| 458 | export const DesktopPreviewTabIdSchema = Schema.String.check(Schema.isTrimmed()).check( | ||
| 459 | Schema.isNonEmpty(), | ||
| 460 | ); | ||
| 461 | |||
| 462 | export const DesktopPreviewNavStatusSchema = Schema.Union([ | ||
| 463 | Schema.Struct({ kind: Schema.Literal("Idle") }), | ||
| 464 | Schema.Struct({ | ||
| 465 | kind: Schema.Literal("Loading"), | ||
| 466 | url: Schema.String, | ||
| 467 | title: Schema.String, | ||
| 468 | }), | ||
| 469 | Schema.Struct({ | ||
| 470 | kind: Schema.Literal("Success"), | ||
| 471 | url: Schema.String, | ||
| 472 | title: Schema.String, | ||
| 473 | }), | ||
| 474 | Schema.Struct({ | ||
| 475 | kind: Schema.Literal("LoadFailed"), | ||
| 476 | url: Schema.String, | ||
| 477 | title: Schema.String, | ||
| 478 | code: Schema.Number, | ||
| 479 | description: Schema.String, | ||
| 480 | }), | ||
| 481 | ]); | ||
| 482 | |||
| 483 | export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState> = Schema.Struct({ | ||
| 484 | tabId: DesktopPreviewTabIdSchema, | ||
| 485 | webContentsId: Schema.NullOr(Schema.Int), | ||
| 486 | navStatus: DesktopPreviewNavStatusSchema, | ||
| 487 | canGoBack: Schema.Boolean, | ||
| 488 | canGoForward: Schema.Boolean, | ||
| 489 | zoomFactor: Schema.Number, | ||
| 490 | controller: Schema.Literals(["human", "agent", "none"]), | ||
| 491 | updatedAt: Schema.String, | ||
| 492 | }); | ||
| 493 | |||
| 458 | export interface DesktopPreviewPointerEvent { | 494 | export interface DesktopPreviewPointerEvent { |
| 459 | tabId: string; | 495 | tabId: string; |
| 460 | phase: "move" | "click"; | 496 | phase: "move" | "click"; |
| ... | @@ -464,6 +500,16 @@ export interface DesktopPreviewPointerEvent { | ... | @@ -464,6 +500,16 @@ export interface DesktopPreviewPointerEvent { |
| 464 | createdAt: string; | 500 | createdAt: string; |
| 465 | } | 501 | } |
| 466 | 502 | ||
| 503 | export const DesktopPreviewPointerEventSchema: Schema.Codec<DesktopPreviewPointerEvent> = | ||
| 504 | Schema.Struct({ | ||
| 505 | tabId: DesktopPreviewTabIdSchema, | ||
| 506 | phase: Schema.Literals(["move", "click"]), | ||
| 507 | x: Schema.Number, | ||
| 508 | y: Schema.Number, | ||
| 509 | sequence: Schema.Int, | ||
| 510 | createdAt: Schema.String, | ||
| 511 | }); | ||
| 512 | |||
| 467 | /** | 513 | /** |
| 468 | * Static config a renderer needs to mount a preview `<webview>`. Returned | 514 | * Static config a renderer needs to mount a preview `<webview>`. Returned |
| 469 | * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer | 515 | * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer |
| ... | @@ -487,6 +533,13 @@ export interface DesktopPreviewWebviewConfig { | ... | @@ -487,6 +533,13 @@ export interface DesktopPreviewWebviewConfig { |
| 487 | preloadUrl: string | null; | 533 | preloadUrl: string | null; |
| 488 | } | 534 | } |
| 489 | 535 | ||
| 536 | export const DesktopPreviewWebviewConfigSchema: Schema.Codec<DesktopPreviewWebviewConfig> = | ||
| 537 | Schema.Struct({ | ||
| 538 | partition: Schema.String, | ||
| 539 | webPreferences: Schema.String, | ||
| 540 | preloadUrl: Schema.NullOr(Schema.String), | ||
| 541 | }); | ||
| 542 | |||
| 490 | export interface DesktopPreviewAnnotationTheme { | 543 | export interface DesktopPreviewAnnotationTheme { |
| 491 | colorScheme: "light" | "dark"; | 544 | colorScheme: "light" | "dark"; |
| 492 | radius: string; | 545 | radius: string; |
| ... | @@ -507,6 +560,27 @@ export interface DesktopPreviewAnnotationTheme { | ... | @@ -507,6 +560,27 @@ export interface DesktopPreviewAnnotationTheme { |
| 507 | fontMono: string; | 560 | fontMono: string; |
| 508 | } | 561 | } |
| 509 | 562 | ||
| 563 | export const DesktopPreviewAnnotationThemeSchema: Schema.Codec<DesktopPreviewAnnotationTheme> = | ||
| 564 | Schema.Struct({ | ||
| 565 | colorScheme: Schema.Literals(["light", "dark"]), | ||
| 566 | radius: Schema.String, | ||
| 567 | background: Schema.String, | ||
| 568 | foreground: Schema.String, | ||
| 569 | popover: Schema.String, | ||
| 570 | popoverForeground: Schema.String, | ||
| 571 | primary: Schema.String, | ||
| 572 | primaryForeground: Schema.String, | ||
| 573 | muted: Schema.String, | ||
| 574 | mutedForeground: Schema.String, | ||
| 575 | accent: Schema.String, | ||
| 576 | accentForeground: Schema.String, | ||
| 577 | border: Schema.String, | ||
| 578 | input: Schema.String, | ||
| 579 | ring: Schema.String, | ||
| 580 | fontSans: Schema.String, | ||
| 581 | fontMono: Schema.String, | ||
| 582 | }); | ||
| 583 | |||
| 510 | export interface DesktopPreviewRecordingFrame { | 584 | export interface DesktopPreviewRecordingFrame { |
| 511 | tabId: string; | 585 | tabId: string; |
| 512 | data: string; | 586 | data: string; |
| ... | @@ -515,6 +589,15 @@ export interface DesktopPreviewRecordingFrame { | ... | @@ -515,6 +589,15 @@ export interface DesktopPreviewRecordingFrame { |
| 515 | receivedAt: string; | 589 | receivedAt: string; |
| 516 | } | 590 | } |
| 517 | 591 | ||
| 592 | export const DesktopPreviewRecordingFrameSchema: Schema.Codec<DesktopPreviewRecordingFrame> = | ||
| 593 | Schema.Struct({ | ||
| 594 | tabId: DesktopPreviewTabIdSchema, | ||
| 595 | data: Schema.String, | ||
| 596 | width: Schema.Number, | ||
| 597 | height: Schema.Number, | ||
| 598 | receivedAt: Schema.String, | ||
| 599 | }); | ||
| 600 | |||
| 518 | export interface DesktopPreviewRecordingArtifact { | 601 | export interface DesktopPreviewRecordingArtifact { |
| 519 | id: string; | 602 | id: string; |
| 520 | tabId: string; | 603 | tabId: string; |
| ... | @@ -524,6 +607,16 @@ export interface DesktopPreviewRecordingArtifact { | ... | @@ -524,6 +607,16 @@ export interface DesktopPreviewRecordingArtifact { |
| 524 | createdAt: string; | 607 | createdAt: string; |
| 525 | } | 608 | } |
| 526 | 609 | ||
| 610 | export const DesktopPreviewRecordingArtifactSchema: Schema.Codec<DesktopPreviewRecordingArtifact> = | ||
| 611 | Schema.Struct({ | ||
| 612 | id: Schema.String, | ||
| 613 | tabId: DesktopPreviewTabIdSchema, | ||
| 614 | path: Schema.String, | ||
| 615 | mimeType: Schema.String, | ||
| 616 | sizeBytes: Schema.Int, | ||
| 617 | createdAt: Schema.String, | ||
| 618 | }); | ||
| 619 | |||
| 527 | export interface DesktopPreviewScreenshotArtifact { | 620 | export interface DesktopPreviewScreenshotArtifact { |
| 528 | id: string; | 621 | id: string; |
| 529 | tabId: string; | 622 | tabId: string; |
| ... | @@ -533,6 +626,16 @@ export interface DesktopPreviewScreenshotArtifact { | ... | @@ -533,6 +626,16 @@ export interface DesktopPreviewScreenshotArtifact { |
| 533 | createdAt: string; | 626 | createdAt: string; |
| 534 | } | 627 | } |
| 535 | 628 | ||
| 629 | export const DesktopPreviewScreenshotArtifactSchema: Schema.Codec<DesktopPreviewScreenshotArtifact> = | ||
| 630 | Schema.Struct({ | ||
| 631 | id: Schema.String, | ||
| 632 | tabId: DesktopPreviewTabIdSchema, | ||
| 633 | path: Schema.String, | ||
| 634 | mimeType: Schema.Literal("image/png"), | ||
| 635 | sizeBytes: Schema.Int, | ||
| 636 | createdAt: Schema.String, | ||
| 637 | }); | ||
| 638 | |||
| 536 | /** | 639 | /** |
| 537 | * Single stack frame captured by react-grab's `getElementContext`. We surface | 640 | * Single stack frame captured by react-grab's `getElementContext`. We surface |
| 538 | * the source file/line so coding agents can jump straight to the JSX that | 641 | * the source file/line so coding agents can jump straight to the JSX that |
| ... | @@ -545,6 +648,13 @@ export interface PickedElementStackFrame { | ... | @@ -545,6 +648,13 @@ export interface PickedElementStackFrame { |
| 545 | columnNumber: number | null; | 648 | columnNumber: number | null; |
| 546 | } | 649 | } |
| 547 | 650 | ||
| 651 | export const PickedElementStackFrameSchema: Schema.Codec<PickedElementStackFrame> = Schema.Struct({ | ||
| 652 | functionName: Schema.NullOr(Schema.String), | ||
| 653 | fileName: Schema.NullOr(Schema.String), | ||
| 654 | lineNumber: Schema.NullOr(Schema.Number), | ||
| 655 | columnNumber: Schema.NullOr(Schema.Number), | ||
| 656 | }); | ||
| 657 | |||
| 548 | /** | 658 | /** |
| 549 | * A successful element pick from the preview webview. All fields are | 659 | * A successful element pick from the preview webview. All fields are |
| 550 | * best-effort — pages that don't ship a React fiber tree (or aren't running | 660 | * best-effort — pages that don't ship a React fiber tree (or aren't running |
| ... | @@ -574,6 +684,19 @@ export interface PickedElementPayload { | ... | @@ -574,6 +684,19 @@ export interface PickedElementPayload { |
| 574 | pickedAt: string; | 684 | pickedAt: string; |
| 575 | } | 685 | } |
| 576 | 686 | ||
| 687 | export const PickedElementPayloadSchema: Schema.Codec<PickedElementPayload> = Schema.Struct({ | ||
| 688 | pageUrl: Schema.String, | ||
| 689 | pageTitle: Schema.NullOr(Schema.String), | ||
| 690 | tagName: Schema.String, | ||
| 691 | selector: Schema.NullOr(Schema.String), | ||
| 692 | htmlPreview: Schema.String, | ||
| 693 | componentName: Schema.NullOr(Schema.String), | ||
| 694 | source: Schema.NullOr(PickedElementStackFrameSchema), | ||
| 695 | stack: Schema.Array(PickedElementStackFrameSchema), | ||
| 696 | styles: Schema.String, | ||
| 697 | pickedAt: Schema.String, | ||
| 698 | }); | ||
| 699 | |||
| 577 | export interface PreviewAnnotationRect { | 700 | export interface PreviewAnnotationRect { |
| 578 | x: number; | 701 | x: number; |
| 579 | y: number; | 702 | y: number; |
| ... | @@ -581,22 +704,47 @@ export interface PreviewAnnotationRect { | ... | @@ -581,22 +704,47 @@ export interface PreviewAnnotationRect { |
| 581 | height: number; | 704 | height: number; |
| 582 | } | 705 | } |
| 583 | 706 | ||
| 707 | export const PreviewAnnotationRectSchema: Schema.Codec<PreviewAnnotationRect> = Schema.Struct({ | ||
| 708 | x: Schema.Number, | ||
| 709 | y: Schema.Number, | ||
| 710 | width: Schema.Number, | ||
| 711 | height: Schema.Number, | ||
| 712 | }); | ||
| 713 | |||
| 584 | export interface PreviewAnnotationPoint { | 714 | export interface PreviewAnnotationPoint { |
| 585 | x: number; | 715 | x: number; |
| 586 | y: number; | 716 | y: number; |
| 587 | } | 717 | } |
| 588 | 718 | ||
| 719 | export const PreviewAnnotationPointSchema: Schema.Codec<PreviewAnnotationPoint> = Schema.Struct({ | ||
| 720 | x: Schema.Number, | ||
| 721 | y: Schema.Number, | ||
| 722 | }); | ||
| 723 | |||
| 589 | export interface PreviewAnnotationElementTarget { | 724 | export interface PreviewAnnotationElementTarget { |
| 590 | id: string; | 725 | id: string; |
| 591 | element: PickedElementPayload; | 726 | element: PickedElementPayload; |
| 592 | rect: PreviewAnnotationRect; | 727 | rect: PreviewAnnotationRect; |
| 593 | } | 728 | } |
| 594 | 729 | ||
| 730 | export const PreviewAnnotationElementTargetSchema: Schema.Codec<PreviewAnnotationElementTarget> = | ||
| 731 | Schema.Struct({ | ||
| 732 | id: Schema.String, | ||
| 733 | element: PickedElementPayloadSchema, | ||
| 734 | rect: PreviewAnnotationRectSchema, | ||
| 735 | }); | ||
| 736 | |||
| 595 | export interface PreviewAnnotationRegionTarget { | 737 | export interface PreviewAnnotationRegionTarget { |
| 596 | id: string; | 738 | id: string; |
| 597 | rect: PreviewAnnotationRect; | 739 | rect: PreviewAnnotationRect; |
| 598 | } | 740 | } |
| 599 | 741 | ||
| 742 | export const PreviewAnnotationRegionTargetSchema: Schema.Codec<PreviewAnnotationRegionTarget> = | ||
| 743 | Schema.Struct({ | ||
| 744 | id: Schema.String, | ||
| 745 | rect: PreviewAnnotationRectSchema, | ||
| 746 | }); | ||
| 747 | |||
| 600 | export interface PreviewAnnotationStrokeTarget { | 748 | export interface PreviewAnnotationStrokeTarget { |
| 601 | id: string; | 749 | id: string; |
| 602 | color: string; | 750 | color: string; |
| ... | @@ -605,6 +753,15 @@ export interface PreviewAnnotationStrokeTarget { | ... | @@ -605,6 +753,15 @@ export interface PreviewAnnotationStrokeTarget { |
| 605 | bounds: PreviewAnnotationRect; | 753 | bounds: PreviewAnnotationRect; |
| 606 | } | 754 | } |
| 607 | 755 | ||
| 756 | export const PreviewAnnotationStrokeTargetSchema: Schema.Codec<PreviewAnnotationStrokeTarget> = | ||
| 757 | Schema.Struct({ | ||
| 758 | id: Schema.String, | ||
| 759 | color: Schema.String, | ||
| 760 | width: Schema.Number, | ||
| 761 | points: Schema.Array(PreviewAnnotationPointSchema), | ||
| 762 | bounds: PreviewAnnotationRectSchema, | ||
| 763 | }); | ||
| 764 | |||
| 608 | export interface PreviewAnnotationStyleChange { | 765 | export interface PreviewAnnotationStyleChange { |
| 609 | targetId: string; | 766 | targetId: string; |
| 610 | selector: string | null; | 767 | selector: string | null; |
| ... | @@ -613,6 +770,15 @@ export interface PreviewAnnotationStyleChange { | ... | @@ -613,6 +770,15 @@ export interface PreviewAnnotationStyleChange { |
| 613 | value: string; | 770 | value: string; |
| 614 | } | 771 | } |
| 615 | 772 | ||
| 773 | export const PreviewAnnotationStyleChangeSchema: Schema.Codec<PreviewAnnotationStyleChange> = | ||
| 774 | Schema.Struct({ | ||
| 775 | targetId: Schema.String, | ||
| 776 | selector: Schema.NullOr(Schema.String), | ||
| 777 | property: Schema.String, | ||
| 778 | previousValue: Schema.String, | ||
| 779 | value: Schema.String, | ||
| 780 | }); | ||
| 781 | |||
| 616 | export interface PreviewAnnotationScreenshot { | 782 | export interface PreviewAnnotationScreenshot { |
| 617 | dataUrl: string; | 783 | dataUrl: string; |
| 618 | width: number; | 784 | width: number; |
| ... | @@ -620,6 +786,14 @@ export interface PreviewAnnotationScreenshot { | ... | @@ -620,6 +786,14 @@ export interface PreviewAnnotationScreenshot { |
| 620 | cropRect: PreviewAnnotationRect; | 786 | cropRect: PreviewAnnotationRect; |
| 621 | } | 787 | } |
| 622 | 788 | ||
| 789 | export const PreviewAnnotationScreenshotSchema: Schema.Codec<PreviewAnnotationScreenshot> = | ||
| 790 | Schema.Struct({ | ||
| 791 | dataUrl: Schema.String, | ||
| 792 | width: Schema.Number, | ||
| 793 | height: Schema.Number, | ||
| 794 | cropRect: PreviewAnnotationRectSchema, | ||
| 795 | }); | ||
| 796 | |||
| 623 | /** | 797 | /** |
| 624 | * A submitted preview annotation. One annotation may reference multiple DOM | 798 | * A submitted preview annotation. One annotation may reference multiple DOM |
| 625 | * elements, freeform regions, and ink strokes. The desktop main process adds | 799 | * elements, freeform regions, and ink strokes. The desktop main process adds |
| ... | @@ -638,6 +812,83 @@ export interface PreviewAnnotationPayload { | ... | @@ -638,6 +812,83 @@ export interface PreviewAnnotationPayload { |
| 638 | createdAt: string; | 812 | createdAt: string; |
| 639 | } | 813 | } |
| 640 | 814 | ||
| 815 | export const PreviewAnnotationPayloadSchema: Schema.Codec<PreviewAnnotationPayload> = Schema.Struct( | ||
| 816 | { | ||
| 817 | id: Schema.String, | ||
| 818 | pageUrl: Schema.String, | ||
| 819 | pageTitle: Schema.NullOr(Schema.String), | ||
| 820 | comment: Schema.String, | ||
| 821 | elements: Schema.Array(PreviewAnnotationElementTargetSchema), | ||
| 822 | regions: Schema.Array(PreviewAnnotationRegionTargetSchema), | ||
| 823 | strokes: Schema.Array(PreviewAnnotationStrokeTargetSchema), | ||
| 824 | styleChanges: Schema.Array(PreviewAnnotationStyleChangeSchema), | ||
| 825 | screenshot: Schema.NullOr(PreviewAnnotationScreenshotSchema), | ||
| 826 | createdAt: Schema.String, | ||
| 827 | }, | ||
| 828 | ); | ||
| 829 | |||
| 830 | export const DesktopPreviewTabInputSchema = Schema.Struct({ | ||
| 831 | tabId: DesktopPreviewTabIdSchema, | ||
| 832 | }); | ||
| 833 | |||
| 834 | export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({ | ||
| 835 | tabId: DesktopPreviewTabIdSchema, | ||
| 836 | webContentsId: Schema.Int.check(Schema.isGreaterThan(0)), | ||
| 837 | }); | ||
| 838 | |||
| 839 | export const DesktopPreviewNavigateInputSchema = Schema.Struct({ | ||
| 840 | tabId: DesktopPreviewTabIdSchema, | ||
| 841 | url: Schema.String, | ||
| 842 | }); | ||
| 843 | |||
| 844 | export const DesktopPreviewConfigInputSchema = Schema.Struct({ | ||
| 845 | environmentId: EnvironmentId, | ||
| 846 | }); | ||
| 847 | |||
| 848 | export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({ | ||
| 849 | theme: DesktopPreviewAnnotationThemeSchema, | ||
| 850 | }); | ||
| 851 | |||
| 852 | export const DesktopPreviewArtifactInputSchema = Schema.Struct({ | ||
| 853 | path: Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()), | ||
| 854 | }); | ||
| 855 | |||
| 856 | export const DesktopPreviewRecordingSaveInputSchema = Schema.Struct({ | ||
| 857 | tabId: DesktopPreviewTabIdSchema, | ||
| 858 | mimeType: Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()), | ||
| 859 | data: Schema.Uint8Array, | ||
| 860 | }); | ||
| 861 | |||
| 862 | export const DesktopPreviewAutomationClickInputSchema = Schema.Struct({ | ||
| 863 | tabId: DesktopPreviewTabIdSchema, | ||
| 864 | input: PreviewAutomationClickInput, | ||
| 865 | }); | ||
| 866 | |||
| 867 | export const DesktopPreviewAutomationTypeInputSchema = Schema.Struct({ | ||
| 868 | tabId: DesktopPreviewTabIdSchema, | ||
| 869 | input: PreviewAutomationTypeInput, | ||
| 870 | }); | ||
| 871 | |||
| 872 | export const DesktopPreviewAutomationPressInputSchema = Schema.Struct({ | ||
| 873 | tabId: DesktopPreviewTabIdSchema, | ||
| 874 | input: PreviewAutomationPressInput, | ||
| 875 | }); | ||
| 876 | |||
| 877 | export const DesktopPreviewAutomationScrollInputSchema = Schema.Struct({ | ||
| 878 | tabId: DesktopPreviewTabIdSchema, | ||
| 879 | input: PreviewAutomationScrollInput, | ||
| 880 | }); | ||
| 881 | |||
| 882 | export const DesktopPreviewAutomationEvaluateInputSchema = Schema.Struct({ | ||
| 883 | tabId: DesktopPreviewTabIdSchema, | ||
| 884 | input: PreviewAutomationEvaluateInput, | ||
| 885 | }); | ||
| 886 | |||
| 887 | export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ | ||
| 888 | tabId: DesktopPreviewTabIdSchema, | ||
| 889 | input: PreviewAutomationWaitForInput, | ||
| 890 | }); | ||
| 891 | |||
| 641 | export interface DesktopBridge { | 892 | export interface DesktopBridge { |
| 642 | getAppBranding: () => DesktopAppBranding | null; | 893 | getAppBranding: () => DesktopAppBranding | null; |
| 643 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; | 894 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; |